diff --git a/Cargo.lock b/Cargo.lock index 6bf8261a8..f0f4dfd94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,7 +217,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -303,7 +303,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.5", + "rustix 1.1.6", "slab", "windows-sys 0.61.2", ] @@ -334,7 +334,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -360,7 +360,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.5", + "rustix 1.1.6", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -665,7 +665,7 @@ checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" dependencies = [ "bitflags 2.13.2", "polling", - "rustix 1.1.5", + "rustix 1.1.6", "tracing", ] @@ -688,7 +688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop 0.14.5", - "rustix 1.1.5", + "rustix 1.1.6", "wayland-backend", "wayland-client", ] @@ -763,9 +763,9 @@ dependencies = [ [[package]] name = "clipboard-win" -version = "5.4.1" +version = "5.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" dependencies = [ "error-code", ] @@ -1563,7 +1563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1888,7 +1888,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.5", + "rustix 1.1.6", "windows-link", ] @@ -3618,7 +3618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3900,7 +3900,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.5", + "rustix 1.1.6", "windows-sys 0.61.2", ] @@ -4140,7 +4140,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4489,15 +4489,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4556,7 +4556,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5097,9 +5097,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.3" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" dependencies = [ "serde", ] @@ -5142,7 +5142,7 @@ dependencies = [ "libc", "log", "memmap2", - "rustix 1.1.5", + "rustix 1.1.6", "thiserror 2.0.21", "wayland-backend", "wayland-client", @@ -5415,10 +5415,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", - "rustix 1.1.5", - "windows-sys 0.59.0", + "rustix 1.1.6", + "windows-sys 0.61.2", ] [[package]] @@ -6199,7 +6199,7 @@ dependencies = [ "log", "proptest", "roxmltree 0.21.2", - "rustix 1.1.5", + "rustix 1.1.6", "ruzstd", "serde", "serde_json", @@ -6256,7 +6256,7 @@ version = "0.18.0" dependencies = [ "flate2", "proptest", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_json", "thiserror 2.0.21", @@ -6517,7 +6517,7 @@ dependencies = [ "egui_kittest", "image", "log", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_json", "toml", @@ -6810,7 +6810,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.5", + "rustix 1.1.6", "scoped-tls", "smallvec", "wayland-sys", @@ -6823,7 +6823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ "bitflags 2.13.2", - "rustix 1.1.5", + "rustix 1.1.6", "wayland-backend", "wayland-scanner", ] @@ -6845,7 +6845,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "rustix 1.1.5", + "rustix 1.1.6", "wayland-client", "xcursor", ] @@ -7183,7 +7183,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7520,7 +7520,7 @@ dependencies = [ "libc", "log", "os_pipe", - "rustix 1.1.5", + "rustix 1.1.6", "thiserror 2.0.21", "tree_magic_mini", "wayland-backend", @@ -7579,7 +7579,7 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix 1.1.5", + "rustix 1.1.6", "x11rb-protocol", ] @@ -7621,7 +7621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -7725,7 +7725,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_repr", "tracing", diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index 8fe3d23b7..ac3a9cf3a 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -116,7 +116,7 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> { let view = vectorcraft_engine::ViewInfo { zoom: 8.0, ..Default::default() }; let err = |e: vectorcraft_engine::EngineError| e.to_string(); let mut drag = |down: Point| -> Result { - let ev = |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), pressure: 1.0 }; + let ev = |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), backquote: false, pressure: 1.0 }; s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 400]]})).map_err(err)?; s.select_tool("directSelection", view).map_err(err)?; let undo = s.doc().map_err(err)?.history.undo.len(); @@ -215,7 +215,7 @@ fn deep(n: usize) -> Result<(), String> { let first = s.doc().map_err(err)?.selection.objects.first().copied(); let at = first.and_then(|id| s.doc().ok()?.doc.node(id)?.geometric_bounds()).map(|b| b.center()); if let Some(down) = at { - let ev = |kind, dx: f64| PointerEvent { kind, pos: down + Vec2::new(dx, 0.0), mods: Mods::default(), pressure: 1.0 }; + let ev = |kind, dx: f64| PointerEvent { kind, pos: down + Vec2::new(dx, 0.0), mods: Mods::default(), backquote: false, pressure: 1.0 }; s.select_tool("selection", view).map_err(err)?; s.pointer(&ev(PointerKind::Down, 0.0), view).map_err(err)?; let mut dx = 0.0; diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index aafb42b9b..08bb6aa8a 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -339,6 +339,7 @@ mod portable { } /// What a write of a picture says where the clipboard carries text only. + #[cfg(target_os = "linux")] const TEXT_ONLY: &str = "this clipboard carries text only"; impl Backend { diff --git a/crates/effects/src/addons.rs b/crates/effects/src/addons.rs index e02c4b22c..10c5977bf 100644 --- a/crates/effects/src/addons.rs +++ b/crates/effects/src/addons.rs @@ -6,7 +6,10 @@ //! with one to four inks. //! - [`halftone`]: Color Effects' live Vector Halftone, and the screens Object › Vector Halftone //! builds its dots with. +//! - [`path`]: Path Precision Tools' live Path Effects, geometry effects built on the add-on's path +//! kernels. pub mod color; pub mod duotone; pub mod halftone; +pub mod path; diff --git a/crates/effects/src/addons/path.rs b/crates/effects/src/addons/path.rs new file mode 100644 index 000000000..556122f63 --- /dev/null +++ b/crates/effects/src/addons/path.rs @@ -0,0 +1,243 @@ +//! Path Effects (Path Precision Tools): live geometry effects that rewrite an object's path with the +//! add-on's path kernels (Effect › Path Effects). Each is evaluated with the other geometry effects +//! ([`crate::apply_geometry`]), so it draws, measures, exports and expands as they do; the geometry +//! is written only by Expand Appearance. +//! +//! Parameters come from documents and scripts, so each is clamped to its documented range, and a +//! path with a coordinate past [`MAX_COORDINATE`] or not finite is left as it is. + +use serde_json::{Value, json}; +use vectorcraft_doc::Dash; +use vectorcraft_geom::{PathData, Point, SubPath}; +use vectorcraft_pathops::addons::pathtools::{self as tools, AnchorSpacing, CloseMode, ExtendMode, SMOOTH_RATIO, SegmentKind}; +use vectorcraft_pathops::addons::{PT_PER_TOLERANCE, retract_tiny_handles, simplify_keep_shape}; + +use crate::catalog::{EffectInfo, Lengths}; +use crate::stroke; +use crate::util::{flag, num, text}; + +/// What every Path Effect's id starts with. +pub const PREFIX: &str = "addon.path."; +pub const DASHES: &str = "addon.path.dashes"; +pub const ADD_ANCHORS: &str = "addon.path.addAnchors"; +pub const EXTEND: &str = "addon.path.extend"; +pub const CLOSE: &str = "addon.path.close"; +pub const SMOOTH: &str = "addon.path.smooth"; +pub const RETRACT: &str = "addon.path.retract"; +pub const REVERSE: &str = "addon.path.reverse"; +pub const SIMPLIFY: &str = "addon.path.simplify"; +pub const SPLIT: &str = "addon.path.split"; + +const MENU: &[&str] = &["Effect", "Path Effects"]; + +/// The dash and gap parameters of [`DASHES`], pair by pair. +const DASH_PAIRS: [(&str, &str); 5] = [("dash1", "gap1"), ("dash2", "gap2"), ("dash3", "gap3"), ("dash4", "gap4"), ("dash5", "gap5")]; +/// Longest dash or gap, and the farthest offset (pt). +const MAX_DASH: f64 = 10_000.0; +/// Longest stretch [`EXTEND`] adds or takes off an end, in points and in percent of the path. +const MAX_EXTEND: f64 = 100_000.0; +const MAX_EXTEND_PERCENT: f64 = 1000.0; +/// Most anchors [`ADD_ANCHORS`] adds to one segment. +const MAX_PER_SEGMENT: f64 = 1000.0; +/// Closest and farthest apart [`ADD_ANCHORS`]' anchors are (pt). +const DISTANCE_RANGE: (f64, f64) = (0.01, 100_000.0); +/// The range of [`SMOOTH`]'s ratio. +const RATIO_RANGE: (f64, f64) = (0.01, 0.5); +/// Longest handle length [`RETRACT`] takes (pt). +const MAX_HANDLE: f64 = 100_000.0; +/// Highest tolerance of [`SIMPLIFY`]. +const MAX_TOLERANCE: f64 = 200.0; +// shortcut: simplifying measures every anchor on every draw, so a larger path draws as it is; lift +// this if the kernel gets an incremental pass. +/// Most anchors [`SIMPLIFY`] takes in one path. +const MAX_SIMPLIFY_ANCHORS: usize = 50_000; +/// A path with a coordinate past this, or one that isn't finite, is left as it is: lengths measured +/// on it would overflow. +const MAX_COORDINATE: f64 = 1e12; + +/// The effects' catalogue entries. +pub(crate) fn catalog() -> Vec { + let entry = |id, label, params, defaults, always| EffectInfo { + id, + label, + menu: MENU, + params, + defaults, + raster: false, + lengths: Lengths { always, absolute: &[] }, + }; + vec![ + entry( + DASHES, + "Dashes to Geometry…", + "{dash1: pt 0..10000 (12), gap1: pt 0..10000 (6), dash2: pt 0..10000 (0), gap2: pt 0..10000 (0), dash3: pt 0..10000 (0), gap3: pt 0..10000 (0), dash4: pt 0..10000 (0), gap4: pt 0..10000 (0), dash5: pt 0..10000 (0), gap5: pt 0..10000 (0), offset: pt -10000..10000 (0; how far into the pattern the path starts; not with alignCorners), alignCorners: bool (false; every run between corners and path ends holds whole periods, stretched or squeezed to fit, with a dash centred on each corner and end)} cuts the path into its dashes, open subpaths along it that the stroke paints as it paints any path. A dash of 0 is a point its stroke's round or projecting cap paints; a pair of two zeros is left out, and the pattern repeats. A pattern of nothing, or of more than 200000 dashes, leaves the path as it is; a dash longer than a closed subpath keeps it closed", + json!({"dash1": 12.0, "gap1": 6.0, "dash2": 0.0, "gap2": 0.0, "dash3": 0.0, "gap3": 0.0, "dash4": 0.0, "gap4": 0.0, "dash5": 0.0, "gap5": 0.0, "offset": 0.0, "alignCorners": false}), + &["dash1", "gap1", "dash2", "gap2", "dash3", "gap3", "dash4", "gap4", "dash5", "gap5", "offset"], + ), + entry( + ADD_ANCHORS, + "Add Anchors…", + "{target: \"all\"|\"curved\"|\"straight\" (all; the segments that get anchors), spacing: \"count\"|\"distance\" (count), count: 1..1000 (1; anchors on each segment, evenly spaced by length), distance: pt 0.01..100000 (20; an anchor every so far along each segment from its start), maxCount: 1..1000 (100; most anchors added to one segment)} adds anchors without changing the shape; past 2 million anchors in all the path stays as it is", + json!({"target": "all", "spacing": "count", "count": 1, "distance": 20.0, "maxCount": 100}), + &["distance"], + ), + entry( + EXTEND, + "Extend Path…", + "{startLength: pt -100000..100000 (10), startPercent: % of the path's length -1000..1000 (0), startShape: \"straight\"|\"arc\" (straight), endLength: pt -100000..100000 (10), endPercent: % of the path's length -1000..1000 (0), endShape: \"straight\"|\"arc\" (straight)} lengthens each end of an open subpath by its length and percent together, or shortens it back along itself when they come to less than 0. Straight runs on along the end's direction; arc follows the circle of the end's curvature, at most half way round. Closed subpaths stay as they are, and so does an end that would be shortened to nothing", + json!({"startLength": 10.0, "startPercent": 0.0, "startShape": "straight", "endLength": 10.0, "endPercent": 0.0, "endShape": "straight"}), + &["startLength", "endLength"], + ), + entry( + CLOSE, + "Close Path…", + "{mode: \"straight\"|\"smooth\" (straight; smooth: the closing segment leaves both ends smoothly)} closes every open subpath of two anchors or more", + json!({"mode": "straight"}), + &[], + ), + entry( + SMOOTH, + "Smooth Anchors…", + "{ratio: 0.01..0.5 (0.3905; a handle's length as a share of the distance to the nearer neighbour; 0.3905 makes a square's corners a circle)} makes every anchor smooth: a lone handle is mirrored, two are brought into line keeping their lengths, and an anchor without handles gets them at right angles to the bisector of its neighbours", + json!({"ratio": SMOOTH_RATIO}), + &[], + ), + entry( + RETRACT, + "Retract Handles…", + "{handles: \"all\"|\"shorter\" (all; shorter: only those shorter than length), length: pt 0..100000 (5)} takes the handles back to their anchors; an anchor that loses one becomes a corner", + json!({"handles": "all", "length": 5.0}), + &["length"], + ), + entry( + REVERSE, + "Reverse Direction", + "{} runs every subpath the other way: the shape stays, and arrowheads, dashes and the effects after it swap ends", + json!({}), + &[], + ), + entry( + SIMPLIFY, + "Simplify Anchors…", + "{tolerance: 0..200 (10; higher removes more anchors and lets the outline move further, up to tolerance × 0.02 pt), protectCorners: bool (true; anchors where the path turns by more than 30° stay)} removes the anchors a path can do without, refitting the handles beside each; open ends stay; a path of more than 50000 anchors stays as it is", + json!({"tolerance": 10.0, "protectCorners": true}), + &[], + ), + entry( + SPLIT, + "Split at Anchors…", + "{anchors: \"corner\"|\"smooth\"|\"both\" (both; smooth: where the handles stand in line, corner: the others)} cuts subpaths into open pieces at those anchors; the shape stays, and an open path's ends aren't cuts", + json!({"anchors": "both"}), + &[], + ), + ] +} + +/// Path Effect `id` with parameters `p` on `path`, which keeps to `max` anchors where anchors are +/// added one by one. `path` as it is when `id` is no such effect or the path can't be measured. +pub(crate) fn apply(id: &str, p: &Value, path: &PathData, max: usize) -> PathData { + if !measurable(path) { + return path.clone(); + } + let edit = |change: &dyn Fn(&mut SubPath)| { + let mut out = path.clone(); + out.subpaths.iter_mut().for_each(change); + out + }; + match id { + DASHES => dashes(path, p), + ADD_ANCHORS => add_anchors(path, p, max).unwrap_or_else(|| path.clone()), + EXTEND => PathData::new(path.subpaths.iter().map(|sp| extend(sp, p)).collect()), + CLOSE => { + let mode = if text(p, "mode", "straight") == "smooth" { CloseMode::Smooth } else { CloseMode::Straight }; + edit(&|sp| { + tools::close(sp, mode, SMOOTH_RATIO); + }) + } + SMOOTH => { + let ratio = num(p, "ratio", SMOOTH_RATIO).clamp(RATIO_RANGE.0, RATIO_RANGE.1); + edit(&|sp| tools::smooth_anchors(sp, ratio)) + } + RETRACT if text(p, "handles", "all") == "shorter" => { + let length = num(p, "length", 5.0).clamp(0.0, MAX_HANDLE); + edit(&|sp| { + retract_tiny_handles(sp, length); + }) + } + RETRACT => edit(&|sp| sp.anchors.iter_mut().for_each(|a| a.retract())), + REVERSE => edit(&SubPath::reverse), + SIMPLIFY if path.anchor_count() <= MAX_SIMPLIFY_ANCHORS => { + let tolerance = num(p, "tolerance", 10.0).clamp(0.0, MAX_TOLERANCE) * PT_PER_TOLERANCE; + simplify_keep_shape(path, tolerance, flag(p, "protectCorners", true)) + } + SPLIT => { + let at = text(p, "anchors", "both"); + let (corners, smooth) = (at != "smooth", at != "corner"); + PathData::new(path.subpaths.iter().flat_map(|sp| tools::split_at_anchors(sp, corners, smooth)).collect()) + } + _ => path.clone(), + } +} + +/// Is every coordinate of `path` within [`MAX_COORDINATE`]? +fn measurable(path: &PathData) -> bool { + let near = |q: Point| q.x.abs() <= MAX_COORDINATE && q.y.abs() <= MAX_COORDINATE; + path.anchors().all(|(_, _, a)| near(a.p) && near(a.h_in) && near(a.h_out)) +} + +/// [`DASHES`]: the dashes of `path` as open subpaths, those of no length as subpaths that stay on +/// their point; `path` as it is when the pattern draws no dashes. +fn dashes(path: &PathData, p: &Value) -> PathData { + let length = |key| num(p, key, 0.0).clamp(0.0, MAX_DASH); + let pairs = DASH_PAIRS.iter().map(|(dash, gap)| (length(dash), length(gap))); + let pattern = pairs.filter(|(d, g)| *d > 0.0 || *g > 0.0).flat_map(|(d, g)| [d, g]).collect(); + let dash = Dash { pattern, offset: num(p, "offset", 0.0).clamp(-MAX_DASH, MAX_DASH), align_corners: flag(p, "alignCorners", false) }; + let Some(dashed) = stroke::dash(&path.to_bezpath(), &dash) else { return path.clone() }; + let mut out = PathData::from_bezpath(&dashed.path); + out.subpaths.extend(dashed.dots.iter().map(|d| SubPath::polyline(&[d.at, d.at], false))); + out +} + +/// [`ADD_ANCHORS`] within `max` anchors in all; `None` past them. +fn add_anchors(path: &PathData, p: &Value, max: usize) -> Option { + let which = match text(p, "target", "all") { + "curved" => SegmentKind::Curved, + "straight" => SegmentKind::Straight, + _ => SegmentKind::All, + }; + let spacing = match text(p, "spacing", "count") { + "distance" => AnchorSpacing::Distance(num(p, "distance", 20.0).clamp(DISTANCE_RANGE.0, DISTANCE_RANGE.1)), + _ => AnchorSpacing::Count(num(p, "count", 1.0).clamp(1.0, MAX_PER_SEGMENT) as usize), + }; + let cap = num(p, "maxCount", 100.0).clamp(1.0, MAX_PER_SEGMENT) as usize; + let mut room = max.saturating_sub(path.anchor_count()); + let mut out = Vec::with_capacity(path.subpaths.len()); + for sp in &path.subpaths { + let added = tools::add_anchors(sp, which, spacing, cap, room)?; + room = room.saturating_sub(added.anchors.len().saturating_sub(sp.anchors.len())); + out.push(added); + } + Some(PathData::new(out)) +} + +/// [`EXTEND`] on one subpath, the start then the end; what can't be done stays as it is. +fn extend(sp: &SubPath, p: &Value) -> SubPath { + let total = sp.anchor_arc_positions().last().copied().unwrap_or(0.0); + let by = |length, percent| { + num(p, length, 0.0).clamp(-MAX_EXTEND, MAX_EXTEND) + num(p, percent, 0.0).clamp(-MAX_EXTEND_PERCENT, MAX_EXTEND_PERCENT) / 100.0 * total + }; + let shape = |key| if text(p, key, "straight") == "arc" { ExtendMode::Arc } else { ExtendMode::Straight }; + let ends = [(true, by("startLength", "startPercent"), shape("startShape")), (false, by("endLength", "endPercent"), shape("endShape"))]; + let mut out = sp.clone(); + for (at_start, by, shape) in ends { + if by != 0.0 + && let Some(changed) = tools::extend_open(&out, at_start, by, shape) + { + out = changed; + } + } + out +} + +#[cfg(test)] +mod tests; diff --git a/crates/effects/src/addons/path/tests.rs b/crates/effects/src/addons/path/tests.rs new file mode 100644 index 000000000..ef99706cc --- /dev/null +++ b/crates/effects/src/addons/path/tests.rs @@ -0,0 +1,278 @@ +use serde_json::{Value, json}; +use vectorcraft_doc::Effect; +use vectorcraft_geom::{Anchor, PathData, Point, Rect, SubPath}; + +use super::*; +use crate::{apply_geometry, effect_info, is_geometry, is_length, is_raster, length_range}; + +fn pt(x: f64, y: f64) -> Point { + Point::new(x, y) +} + +fn line(pts: &[(f64, f64)], closed: bool) -> PathData { + PathData::single(SubPath::polyline(&pts.iter().map(|&(x, y)| pt(x, y)).collect::>(), closed)) +} + +fn square() -> PathData { + line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true) +} + +/// A curve: a smooth anchor between two ends. +fn curve() -> PathData { + let mid = Anchor::smooth(pt(50.0, -30.0), pt(70.0, -30.0)); + PathData::single(SubPath::new( + vec![ + Anchor { h_out: pt(10.0, -20.0), ..Anchor::corner(pt(0.0, 0.0)) }, + mid, + Anchor { h_in: pt(90.0, -20.0), ..Anchor::corner(pt(100.0, 0.0)) }, + ], + false, + )) +} + +fn run(id: &str, params: Value, path: &PathData) -> PathData { + let e = Effect { id: id.into(), params, visible: true }; + apply_geometry(&[e], path, path.bounds().unwrap_or(Rect::ZERO)) +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-3 +} + +const ALL: [&str; 9] = [DASHES, ADD_ANCHORS, EXTEND, CLOSE, SMOOTH, RETRACT, REVERSE, SIMPLIFY, SPLIT]; + +#[test] +fn the_effects_are_geometry_effects_of_the_path_effects_menu() { + for id in ALL { + let info = effect_info(id).unwrap(); + assert!(is_geometry(id) && !is_raster(id), "{id}"); + assert_eq!(info.menu, ["Effect", "Path Effects"], "{id}"); + assert!(id.starts_with(PREFIX)); + } + assert!(is_length(DASHES, "gap5", false) && is_length(EXTEND, "endLength", false) && !is_length(EXTEND, "endPercent", false)); + assert_eq!(length_range(DASHES, "offset"), Some(-10_000.0..=10_000.0)); +} + +#[test] +fn dashes_become_open_subpaths_along_the_path() { + let out = run(DASHES, json!({"dash1": 10, "gap1": 5}), &line(&[(0.0, 0.0), (100.0, 0.0)], false)); + assert_eq!(out.subpaths.len(), 7, "periods of 15 on 100"); + assert!(out.subpaths.iter().all(|s| !s.closed)); + assert!(near(out.subpaths[1].anchors[0].p.x, 15.0) && near(out.subpaths[1].anchors[1].p.x, 25.0)); + // Two pairs, and a pair of zeros between them left out. + let out = run(DASHES, json!({"dash1": 10, "gap1": 5, "dash3": 20, "gap3": 5}), &line(&[(0.0, 0.0), (100.0, 0.0)], false)); + assert!(near(out.subpaths[1].anchors[0].p.x, 15.0) && near(out.subpaths[1].anchors[1].p.x, 35.0)); + assert!(near(out.subpaths[2].anchors[0].p.x, 40.0)); + // The offset starts part way into the pattern. + let out = run(DASHES, json!({"dash1": 10, "gap1": 5, "offset": 5}), &line(&[(0.0, 0.0), (100.0, 0.0)], false)); + assert!(near(out.subpaths[0].anchors[1].p.x, 5.0)); +} + +#[test] +fn aligned_dashes_centre_on_corners_and_ends() { + let open = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false); + let out = run(DASHES, json!({"dash1": 10, "gap1": 10, "alignCorners": true}), &open); + let on = |p: Point| out.subpaths.iter().any(|s| (0..s.segment_count()).any(|i| s.anchors[i].p == p || s.anchors[i + 1].p == p)); + assert!(on(pt(0.0, 0.0)) && on(pt(100.0, 100.0)), "a dash on each end"); + let corner = out.subpaths.iter().find(|s| s.anchors.iter().any(|a| a.p == pt(100.0, 0.0))).expect("a dash over the corner"); + assert!(near(corner.anchors[0].p.distance(pt(100.0, 0.0)), corner.anchors[2].p.distance(pt(100.0, 0.0))), "centred: {corner:?}"); +} + +#[test] +fn zero_dashes_are_points_and_patterns_of_nothing_leave_the_path() { + let path = line(&[(0.0, 0.0), (100.0, 0.0)], false); + let dots = run(DASHES, json!({"dash1": 0, "gap1": 25}), &path); + assert_eq!(dots.subpaths.len(), 5); + assert!(dots.subpaths.iter().all(|s| s.anchors.len() == 2 && s.anchors[0].p == s.anchors[1].p)); + for params in [ + json!({"dash1": 0, "gap1": 0}), + json!({"dash1": -5, "gap1": -5}), + json!({"dash1": 0.0001, "gap1": 0.0001}), + json!({"dash1": "x", "gap1": null}), + ] { + assert_eq!(run(DASHES, params.clone(), &path), path, "{params}"); + } +} + +#[test] +fn add_anchors_spaces_them_and_keeps_to_the_caps() { + let square = square(); + assert_eq!(run(ADD_ANCHORS, json!({}), &square).anchor_count(), 8, "one on each segment"); + assert_eq!(run(ADD_ANCHORS, json!({"count": 3}), &square).anchor_count(), 16); + assert_eq!(run(ADD_ANCHORS, json!({"count": 3, "maxCount": 2}), &square).anchor_count(), 12); + let out = run(ADD_ANCHORS, json!({"spacing": "distance", "distance": 30}), &square); + assert_eq!(out.anchor_count(), 16, "at 30, 60 and 90 on each side"); + assert!(near(out.subpaths[0].anchors[2].p.x, 60.0)); + assert_eq!(run(ADD_ANCHORS, json!({"spacing": "distance", "distance": 25}), &square).anchor_count(), 16, "none on the segment's end"); + assert_eq!(run(ADD_ANCHORS, json!({"spacing": "distance", "distance": 1e-9, "maxCount": 2}), &square).anchor_count(), 12); + // Only the segments asked for. + let mixed = PathData::new(vec![curve().subpaths[0].clone(), square.subpaths[0].clone()]); + assert_eq!(run(ADD_ANCHORS, json!({"target": "curved"}), &mixed).anchor_count(), 7 + 2); + assert_eq!(run(ADD_ANCHORS, json!({"target": "straight"}), &mixed).anchor_count(), 7 + 4); + // The shape stays. + let out = run(ADD_ANCHORS, json!({"count": 4}), &curve()); + assert_eq!(out.anchor_count(), 3 + 8); + assert!(near(out.length(), curve().length())); + assert_eq!(out.bounds().map(|b| (b.y0 * 100.0).round()), curve().bounds().map(|b| (b.y0 * 100.0).round())); +} + +#[test] +fn add_anchors_past_the_budget_leaves_the_path() { + let path = square(); + let most = json!({"count": 1000, "maxCount": 1000}); + assert_eq!(super::apply(ADD_ANCHORS, &most, &path, 4003), path); + assert_eq!(super::apply(ADD_ANCHORS, &most, &path, 4004).anchor_count(), 4004); + // Many segments, each with the most anchors: refused before they are built. + let long = line(&(0..4000).map(|i| (i as f64, 0.0)).collect::>(), false); + assert_eq!(run(ADD_ANCHORS, json!({"count": 1000, "maxCount": 1000}), &long).anchor_count(), 4000, "4 million would pass the budget"); +} + +#[test] +fn extend_lengthens_and_shortens_each_end() { + let path = line(&[(0.0, 0.0), (100.0, 0.0)], false); + let out = run(EXTEND, json!({"startLength": 20, "endLength": 10}), &path); + let xs: Vec = out.subpaths[0].anchors.iter().map(|a| a.p.x).collect(); + assert_eq!(xs, [-20.0, 0.0, 100.0, 110.0]); + // Percent of the path's length, adding to the length; negative shortens. + let out = run(EXTEND, json!({"startLength": 0, "endLength": 5, "endPercent": 10}), &path); + assert!(out.subpaths[0].anchors.last().is_some_and(|a| near(a.p.x, 115.0))); + let out = run(EXTEND, json!({"startLength": -10, "endLength": 0, "endPercent": -50}), &path); + let xs: Vec = out.subpaths[0].anchors.iter().map(|a| a.p.x).collect(); + assert!(near(xs[0], 10.0) && near(xs[1], 50.0), "{xs:?}"); + // An arc carries on round the end's curve. + let arc = run(EXTEND, json!({"startLength": 0, "endLength": 40, "endShape": "arc"}), &curve()); + let straight = run(EXTEND, json!({"startLength": 0, "endLength": 40}), &curve()); + assert!(arc.anchor_count() >= straight.anchor_count()); + assert!((arc.length() - straight.length()).abs() < 0.1); + assert_ne!(arc.subpaths[0].anchors.last().map(|a| a.p), straight.subpaths[0].anchors.last().map(|a| a.p)); +} + +#[test] +fn extend_leaves_what_it_cannot_change() { + let closed = square(); + assert_eq!(run(EXTEND, json!({"endLength": 50}), &closed), closed); + let path = line(&[(0.0, 0.0), (100.0, 0.0)], false); + assert_eq!(run(EXTEND, json!({"startLength": 0, "endLength": -500}), &path), path, "nothing would be left"); + assert_eq!(run(EXTEND, json!({"startLength": 0, "endLength": 0, "endPercent": -100}), &path), path); + let one = PathData::single(SubPath::polyline(&[pt(1.0, 1.0)], false)); + assert_eq!(run(EXTEND, json!({}), &one), one); +} + +#[test] +fn close_joins_open_subpaths_straight_or_smooth() { + let open = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false); + let out = run(CLOSE, json!({}), &open); + assert!(out.subpaths[0].closed && out.subpaths[0].segment_is_line(2)); + let out = run(CLOSE, json!({"mode": "smooth"}), &open); + assert!(out.subpaths[0].closed && !out.subpaths[0].segment_is_line(2)); + assert_eq!(run(CLOSE, json!({}), &square()), square()); + let one = PathData::single(SubPath::polyline(&[pt(1.0, 1.0)], false)); + assert_eq!(run(CLOSE, json!({}), &one), one); +} + +#[test] +fn smooth_makes_a_square_round_and_clamps_its_ratio() { + let out = run(SMOOTH, json!({}), &square()); + assert!(out.subpaths[0].anchors.iter().all(|a| near(a.h_out.distance(a.p), 39.05) && a.has_in())); + let at = |ratio: f64| run(SMOOTH, json!({ "ratio": ratio }), &square()).subpaths[0].anchors[0]; + assert!(near(at(9.0).h_out.distance(pt(0.0, 0.0)), 50.0)); + assert!(near(at(-9.0).h_out.distance(pt(0.0, 0.0)), 1.0)); +} + +#[test] +fn retract_takes_all_handles_or_the_short_ones() { + let out = run(RETRACT, json!({}), &curve()); + assert!(out.subpaths[0].anchors.iter().all(|a| !a.has_in() && !a.has_out())); + // The ends' handles are about 22 pt long and the middle anchor's 20. + let out = run(RETRACT, json!({"handles": "shorter", "length": 21}), &curve()); + let a = &out.subpaths[0].anchors; + assert!(!a[1].has_in() && !a[1].has_out() && a[0].has_out() && a[2].has_in()); + assert_eq!(run(RETRACT, json!({"handles": "shorter", "length": 1}), &curve()), curve()); +} + +#[test] +fn reverse_runs_the_other_way_and_twice_is_as_it_was() { + let out = run(REVERSE, json!({}), &curve()); + assert_eq!(out.subpaths[0].anchors[0].p, pt(100.0, 0.0)); + assert_eq!(run(REVERSE, json!({}), &out), curve()); +} + +#[test] +fn simplify_takes_anchors_within_the_tolerance_and_stops_at_its_cap() { + let pts: Vec<(f64, f64)> = (0..=40).map(|i| (i as f64, (i as f64 / 40.0 * std::f64::consts::PI).sin() * 3.0)).collect(); + let path = line(&pts, false); + let out = run(SIMPLIFY, json!({"tolerance": 200}), &path); + assert!(out.anchor_count() < 20, "{}", out.anchor_count()); + assert_eq!(run(SIMPLIFY, json!({"tolerance": 0}), &path), path); + // Corners stay unless told otherwise. + let corner = line(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], false); + assert_eq!(run(SIMPLIFY, json!({"tolerance": 200}), &corner), corner); + assert_eq!(run(SIMPLIFY, json!({"tolerance": 200, "protectCorners": false}), &corner).anchor_count(), 2); + let long = line(&(0..=MAX_SIMPLIFY_ANCHORS).map(|i| (i as f64, 0.0)).collect::>(), false); + assert_eq!(run(SIMPLIFY, json!({}), &long).anchor_count(), long.anchor_count()); +} + +#[test] +fn split_cuts_at_the_anchors_asked_for() { + let mixed = PathData::single(SubPath::new( + vec![ + Anchor::corner(pt(0.0, 0.0)), + Anchor::smooth(pt(50.0, 0.0), pt(60.0, 0.0)), + Anchor::corner(pt(100.0, 0.0)), + Anchor::corner(pt(100.0, 50.0)), + ], + false, + )); + assert_eq!(run(SPLIT, json!({}), &mixed).subpaths.len(), 3); + assert_eq!(run(SPLIT, json!({"anchors": "smooth"}), &mixed).subpaths.len(), 2); + assert_eq!(run(SPLIT, json!({"anchors": "corner"}), &mixed).subpaths.len(), 2); + let out = run(SPLIT, json!({}), &square()); + assert_eq!(out.subpaths.len(), 4); + assert!(out.subpaths.iter().all(|s| !s.closed && s.anchors.len() == 2)); + assert!(near(out.length(), 400.0)); + assert_eq!(run(SPLIT, json!({"anchors": "smooth"}), &square()), square(), "no smooth anchors"); +} + +/// Whatever a file holds: every effect takes wrong types, huge numbers and paths of nothing, of one +/// anchor or past measuring, and keeps to the budget. +#[test] +fn junk_params_and_paths_are_harmless() { + let junk = [ + json!({}), + json!(null), + json!([1, 2]), + json!("x"), + json!({"dash1": 1e308, "gap1": 1e308, "dash2": -1e308, "gap2": "x", "offset": 1e308, "alignCorners": "yes"}), + json!({"dash1": 1e-300, "gap1": 1e-300, "offset": -1e308, "alignCorners": true}), + json!({"target": 3, "spacing": ["distance"], "count": 1e308, "distance": 1e-308, "maxCount": -1e308}), + json!({"spacing": "distance", "distance": -1e308, "maxCount": 1e308}), + json!({"startLength": 1e308, "startPercent": 1e308, "startShape": 7, "endLength": -1e308, "endPercent": -1e308, "endShape": "arc"}), + json!({"mode": {}, "ratio": 1e308, "handles": "shorter", "length": 1e308, "tolerance": 1e308, "protectCorners": "no", "anchors": 9}), + json!({"ratio": -1e308, "length": -1e308, "tolerance": -1e308, "anchors": "none"}), + ]; + let far = line(&[(1e308, -1e308), (-1e308, 1e308), (1e300, 0.0)], false); + let nan = line(&[(f64::NAN, 0.0), (f64::INFINITY, 1.0)], false); + let tiny = line(&[(0.0, 0.0), (1e-300, 0.0), (1e-300, 1e-300)], true); + let same = line(&[(5.0, 5.0), (5.0, 5.0), (5.0, 5.0)], true); + let empty = PathData::single(SubPath::default()); + let one = PathData::single(SubPath::polyline(&[pt(1.0, 1.0)], true)); + let paths = [square(), curve(), far.clone(), nan.clone(), tiny, same, empty, one, PathData::default()]; + for id in ALL { + for params in &junk { + for path in &paths { + let out = super::apply(id, &crate::merged_params(id, params), path, 100_000); + assert!(out.anchor_count() <= 100_000.max(path.anchor_count()) + 8 * path.subpaths.len(), "{id} {params}"); + } + } + // Compared as text: NaN never equals itself. + assert_eq!(format!("{:?}", super::apply(id, &json!({}), &nan, 100)), format!("{nan:?}"), "{id}"); + assert_eq!(super::apply(id, &json!({}), &far, 100), far, "{id}"); + } +} + +/// The dash generator's limit holds: a pattern too fine for the path leaves it solid. +#[test] +fn too_many_dashes_leave_the_path() { + let long = line(&[(0.0, 0.0), (1e9, 0.0)], false); + assert_eq!(run(DASHES, json!({"dash1": 1, "gap1": 1}), &long), long); +} diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 5fdef797c..bab9ee6ed 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -793,6 +793,7 @@ pub fn effect_catalog() -> Vec { } v.extend(crate::addons::color::catalog()); v.extend(crate::addons::halftone::catalog()); + v.extend(crate::addons::path::catalog()); v.push(g(TRAP, "Trap…", PATHFINDER, trap::EFFECT_PARAMS_DOC, trap::effect_defaults())); v.push(g( CROP_MARKS, diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index 542098201..f6f007b5c 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -265,6 +265,7 @@ fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomCont "convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" => stylize::convert_to_shape(id, b, p), "stylize.roundCorners" => stylize::round_corners(path, num(p, "radius", 10.0)), "stylize.scribble" => stylize::scribble(path, b, p), + _ if id.starts_with(crate::addons::path::PREFIX) => crate::addons::path::apply(id, p, path, max), _ => match id.strip_prefix("warp.").and_then(WarpStyle::from_id) { Some(style) => warp::warp(path, b, style, p, max), None => path.clone(), diff --git a/crates/engine/src/cmd/addons/cleanup.rs b/crates/engine/src/cmd/addons/cleanup.rs index 0b29b07e2..4f96c2634 100644 --- a/crates/engine/src/cmd/addons/cleanup.rs +++ b/crates/engine/src/cmd/addons/cleanup.rs @@ -1,10 +1,12 @@ //! Artwork Cleanup (`addon.cleanup.*`), a built-in add-on: Simplify Anchors (Keep Shape), the //! Document Health Check (check, show and fix the issues it finds), Deep Ungroup and Rejoin Path -//! Pieces. Its panel and its submenu, Object › Plug-ins › Artwork Cleanup, are the UI's. +//! Pieces, and its text tools ([`text`]). Its panel and its submenu, Object › Plug-ins › Artwork +//! Cleanup, are the UI's. mod checks; #[cfg(test)] mod tests; +mod text; use serde_json::{Map, Value, json}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; @@ -16,9 +18,8 @@ use super::super::edit::selected_roots; use super::super::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, number_or, switch_or, text_param}; use crate::{EngineError, Result, Session}; -/// Simplify Anchors' tolerance by default, and the drift (pt) each step of it allows. +/// Simplify Anchors' tolerance by default. const TOLERANCE: f64 = 20.0; -pub(super) const PT_PER_TOLERANCE: f64 = 0.02; /// Rejoin Path Pieces' gap by default, and the widest (pt). const GAP: f64 = 0.1; const MAX_GAP: f64 = 6.0; @@ -30,7 +31,7 @@ const FIX_ROUNDS: usize = 3; const MAX_LISTED: usize = 1000; pub fn specs() -> Vec { - vec![ + let mut v = vec![ cmd!( "addon.cleanup.simplify", "Simplify Anchors (Keep Shape)…", @@ -85,7 +86,9 @@ pub fn specs() -> Vec { has_selection, rejoin ), - ] + ]; + v.extend(text::specs()); + v } /// The paths (not guides) under `roots`, compound path members included. @@ -119,7 +122,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result { let changed: Vec<(NodeId, PathData)> = paths .iter() .filter_map(|(id, path)| { - let np = po::simplify_keep_shape(path, tolerance * PT_PER_TOLERANCE, protect); + let np = po::simplify_keep_shape(path, tolerance * po::PT_PER_TOLERANCE, protect); (np != **path).then_some((*id, np)) }) .collect(); diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs new file mode 100644 index 000000000..26a37efbf --- /dev/null +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -0,0 +1,865 @@ +//! Artwork Cleanup's text tools: Break Text into Lines / Paragraphs / Words / Glyphs, Combine Point +//! Text, Split Text at Cursor, Remove Line / Paragraph Breaks, Set Alignment, Keep Position, Reset +//! Text Transform and Replace Missing Fonts. Each is one undo step. Pieces keep their characters' +//! styles and stay where the layout drew them: each is moved so the glyph of its first character +//! lands where it was. + +#[cfg(test)] +mod tests; + +use serde_json::{Value, json}; +use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeId, NodeKind, ParaStyle, RubySpan, TextKind, TextObject}; +use vectorcraft_geom::{Affine, Point, Vec2}; +use vectorcraft_text::{FontDb, FontMatch, TextLayout, edit}; + +use crate::cmd::textedit::editing_range; +use crate::cmd::typecmd::refresh_bounds; +use crate::cmd::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, switch_or, text_param}; +use crate::{EngineError, Result, Session}; + +/// Most text objects one command takes. +const MAX_TEXTS: usize = 10_000; +/// Most pieces Break Text and Split Text at Cursor make at once. +const MAX_PIECES: usize = 20_000; +/// Most breaks Remove Line / Paragraph Breaks removes at once. +const MAX_BREAKS: usize = 200_000; +/// Most pieces Combine Point Text tries to join, counted over every try: past it, what it joined +/// so far is the result. +const MAX_JOIN_TRIES: usize = 200_000; +/// How far (pt) a glyph Combine Point Text sets may land from where it was. +const TOLERANCE: f64 = 0.01; +/// Combine Point Text: pieces whose baselines are within this many ems of each other share a line +/// (the difference becomes baseline shift); a gap wider than `MAX_GAP` ems, or an overlap of more +/// than `MAX_OVERLAP`, starts another object. +const SAME_LINE: f64 = 0.4; +const MAX_GAP: f64 = 10.0; +const MAX_OVERLAP: f64 = 1.0; +/// Tracking Combine Point Text may give a character (thousandths of an em). +const TRACKING: std::ops::RangeInclusive = -1000.0..=10000.0; + +pub fn specs() -> Vec { + vec![ + cmd!( + "addon.cleanup.breakText", + "Break Text", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{by?: \"lines\"|\"paragraphs\"|\"words\"|\"glyphs\" (lines), ids?: [id…] (default: the selection)} each horizontal point or area type object becomes point type objects, one per line, paragraph, word or glyph, each where its characters were and with their character styles, as one undo step; spaces between pieces go, a paragraph's lines stay lines (a wrapped line ends in a line break), justified lines close up, and a paragraph broken out whole keeps its bullet (not its number; other pieces have no list markers). Type on a path, vertical type, threaded type and area type whose text doesn't fit stay as they are → {ids (the pieces, selected), skipped}", + has_selection, + break_text + ), + cmd!( + "addon.cleanup.combineText", + "Combine Point Text", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{ids?: [id…] (default: the selection)} merge one-line horizontal point type into as few objects as it can, every glyph staying where it was: pieces turned and sheared alike whose baselines line up (within 0.4 em; the difference becomes baseline shift) join left to right, a space goes between pieces at least half a space apart, and tracking on the character before each piece keeps the spacing; each object keeps its character styles, and the leftmost piece of each run its place and paint, as one undo step → {ids (the objects made, selected), combined (pieces merged into them)}", + has_selection, + combine_text + ), + cmd!( + "addon.cleanup.removeBreaks", + "Remove Breaks", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{kind: \"line\"|\"paragraph\", ids?: [id…]} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool, else in all the text of the selected type, as one undo step; a break between two words becomes a space → {ids, removed}", + has_selection, + remove_breaks + ), + cmd!( + "addon.cleanup.splitText", + "Split Text at Cursor", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{} while a Type tool edits horizontal point or area type: the text before the selected characters, they and the text after become point type objects of their own (at a cursor: before and after it), each where its characters were, as one undo step; spaces at the ends of each piece go, and editing ends with the pieces selected → {ids}", + editing_text, + split_text + ), + cmd!( + "addon.cleanup.alignInPlace", + "Set Alignment, Keep Position", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{align: \"left\"|\"center\"|\"right\", ids?: [id…] (default: the selection)} align every paragraph of each horizontal point type object so, moving the object so its longest line stays where it is (one line: the text doesn't move), as one undo step → {ids (the objects changed)}", + has_selection, + align_in_place + ), + cmd!( + "addon.cleanup.resetTextTransform", + "Reset Text Transform", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{scale?: bool (false: the horizontal scale stays; true: it goes too, the object's and every character's), ids?: [id…] (default: the selection)} take the rotation, shear and reflection off each point type object, its size and the centre of its text staying, as one undo step → {ids (the objects changed)}", + has_selection, + reset_transform + ), + cmd!( + "addon.cleanup.replaceFonts", + "Replace Missing Fonts…", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{font?: family (default: the default type font, Source Sans 3; any of its names)} set every character of the document's type whose font family isn't available in `font`, in the style it has closest to the one each had, as one undo step → {runs (style runs changed), font}", + has_doc, + replace_fonts + ), + ] +} + +/// Enabled while a Type tool edits text. +fn editing_text(s: &Session) -> std::result::Result<(), String> { + match s.tool_options().get("editing") { + Some(v) if !v.is_null() => Ok(()), + _ => Err("Click in type with a Type tool to split it".into()), + } +} + +/// The type objects under `roots` (inside groups and layers, not live art) that can be edited. +fn texts_under(d: &Document, roots: &[NodeId]) -> Result> { + fn visit(n: &Node, out: &mut Vec) { + match &n.kind { + NodeKind::Text(_) => out.push(n.id), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, out)), + _ => {} + } + } + let mut out = vec![]; + roots.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, &mut out)); + let editable = d.editable(); + out.retain(|id| editable(*id)); + out.dedup(); + if out.len() > MAX_TEXTS { + return Err(EngineError::Other("Too many type objects to change at once".into())); + } + Ok(out) +} + +fn text_of(d: &Document, id: NodeId) -> Option<&TextObject> { + match &d.node(id)?.kind { + NodeKind::Text(t) => Some(t), + _ => None, + } +} + +fn layout(t: &TextObject) -> TextLayout { + vectorcraft_text::layout(FontDb::global(), t) +} + +/// Where the layout draws the character at `byte` (the one it starts in): its glyph's pen position. +fn glyph_at(lay: &TextLayout, byte: usize) -> Option { + let start = lay.glyphs.iter().filter(|g| g.len > 0 && g.byte <= byte).map(|g| g.byte).max()?; + lay.glyphs.iter().find(|g| g.len > 0 && g.byte == start).map(|g| g.origin) +} + +/// The pen position of the glyph that starts at `byte`, if one does. +fn glyph_starting(lay: &TextLayout, byte: usize) -> Option { + lay.glyphs.iter().find(|g| g.len > 0 && g.byte == byte).map(|g| g.origin) +} + +/// The first glyph of line `l`, in text order. +fn line_start(lay: &TextLayout, l: usize) -> Option { + let line = lay.lines.get(l)?; + let g = lay.glyphs.get(line.glyph_start..line.glyph_end)?.iter().filter(|g| g.len > 0).min_by_key(|g| g.byte)?; + Some(g.origin) +} + +/// `a..b` of `text` without the white space at its ends; None when nothing else is left. +fn trimmed(text: &str, a: usize, b: usize) -> Option<(usize, usize)> { + let s = text.get(a..b)?; + let start = a + (s.len() - s.trim_start().len()); + let end = a + s.trim_end().len(); + (start < end).then_some((start, end)) +} + +/// Paragraph alignment as point type keeps it: justified lines align to their side. +fn point_justify(j: Justify) -> Justify { + match j { + Justify::JustifyLeft | Justify::JustifyAll => Justify::Left, + Justify::JustifyCenter => Justify::Center, + Justify::JustifyRight => Justify::Right, + j => j, + } +} + +#[derive(Clone, Copy, PartialEq)] +enum By { + Lines, + Paragraphs, + Words, + Glyphs, +} + +/// A type object being cut into point type pieces: its text as drawn (Shrink Text to Fit's sizes +/// taken in) and its layout. +struct Source { + t: TextObject, + lay: TextLayout, + text: String, +} + +impl Source { + /// Horizontal point or area type that isn't threaded and whose text all fits; None otherwise. + fn of(d: &Document, id: NodeId) -> Option { + let t = text_of(d, id)?; + if t.vertical || matches!(t.kind, TextKind::OnPath { .. }) || d.text_threads.iter().any(|th| th.contains(&id)) { + return None; + } + let lay = layout(t); + // Text left out of a frame would be lost with it. + if lay.overflow { + return None; + } + let t = if (lay.fit_scale - 1.0).abs() > 1e-9 { vectorcraft_text::scaled(t, lay.fit_scale) } else { t.clone() }; + Some(Self { text: t.plain_text(), t, lay }) + } + + /// The byte ranges of the pieces `by` cuts the text into. + fn ranges(&self, by: By) -> Vec<(usize, usize)> { + let text = &self.text; + match by { + By::Paragraphs => { + let mut out = vec![]; + let mut a = 0; + for (i, _) in text.match_indices('\n') { + out.push((a, i)); + a = i + 1; + } + out.push((a, text.len())); + out + } + By::Lines => self.lay.lines.iter().map(|l| (l.start, l.end)).collect(), + By::Words => { + let mut out = vec![]; + for l in &self.lay.lines { + let Some(s) = text.get(l.start..l.end) else { continue }; + let mut word: Option = None; + for (i, c) in s.char_indices() { + match (c.is_whitespace(), word) { + (true, Some(w)) => { + out.push((l.start + w, l.start + i)); + word = None; + } + (false, None) => word = Some(i), + _ => {} + } + } + if let Some(w) = word { + out.push((l.start + w, l.end)); + } + } + out + } + By::Glyphs => { + // One piece per cluster (a ligature's characters stay together). + let mut clusters = std::collections::BTreeMap::new(); + for g in self.lay.glyphs.iter().filter(|g| g.len > 0) { + let end = clusters.entry(g.byte).or_insert(g.byte + g.len); + *end = (*end).max(g.byte + g.len); + } + clusters.into_iter().collect() + } + } + } + + /// Point type holding bytes `a..b` (white space at its ends left out) where they were drawn. + /// Lines the layout wrapped end in a forced line break; `lists` keeps bulleted paragraphs' + /// bullets where the piece starts its paragraph. + fn piece(&self, (a, b): (usize, usize), lists: bool) -> Option { + let (a, b) = trimmed(&self.text, a, b)?; + let mut p = TextObject::point(Point::ZERO, "", CharStyle::default()); + p.runs = edit::slice_runs(&self.t.runs, a, b); + p.xf = self.t.xf; + p.optical_margin = self.t.optical_margin; + p.system_layout = self.t.system_layout; + p.fractional_widths = self.t.fractional_widths; + p.ruby = + self.t.ruby.iter().filter(|r| a <= r.start && r.end <= b).map(|r| RubySpan { start: r.start - a, end: r.end - a, ..r.clone() }).collect(); + // The paragraphs it holds, with their styles; the first keeps its bullet only from its start. + let starts_para = self.text.get(..a).is_some_and(|s| s.rsplit('\n').next().is_some_and(|line| line.trim().is_empty())); + let styles = self.t.paragraph_styles(); + let mut paras: Vec = styles.get(self.t.paragraphs_in(a, b))?.to_vec(); + for (i, para) in paras.iter_mut().enumerate() { + para.justify = point_justify(para.justify); + if !(lists && (i > 0 || starts_para) && para.list.as_ref().is_some_and(|l| l.number.is_none())) { + para.list = None; + } + } + p.set_paragraph_styles(paras); + // Lines the layout wrapped end in a forced line break, in place of the spaces they ended in. + let mut wraps = vec![]; + for next in self.lay.lines.iter().skip(1).filter(|l| a < l.start && l.start < b) { + let content = self.text.get(..next.start)?.trim_end_matches([' ', '\t']); + // A line that ends in a break of its own keeps it. + if !content.ends_with(['\n', '\u{2028}']) { + wraps.push((content.len().max(a) - a, next.start - a)); + } + } + for &(s, e) in wraps.iter().rev() { + edit::replace_range(&mut p.runs, s, e, "\u{2028}"); + p.ruby_after_replace(s, e, '\u{2028}'.len_utf8()); + } + self.place(&mut p, a)?; + Some(p) + } + + /// Move `p` (cut from byte `a`) so the glyph of its first character lands where it was drawn. + /// A piece of several lines that starts inside a left-aligned line takes a first-line indent, + /// so its later lines stay where they were too. + fn place(&self, p: &mut TextObject, a: usize) -> Option<()> { + let lay = layout(p); + let mut shift: Vec2 = glyph_at(&self.lay, a)? - glyph_at(&lay, 0)?; + let first = self.lay.lines.iter().rposition(|l| l.start <= a); + if let (Some(now), Some(was)) = (line_start(&lay, 1), first.and_then(|l| line_start(&self.lay, l + 1))) { + let off = was.x - (now.x + shift.x); + let left = match p.para.justify { + Justify::Left => true, + Justify::Auto => lay.lines.first().is_some_and(|l| !l.rtl), + _ => false, + }; + if left && off.abs() > 1e-6 { + p.edit_paras(Some(0..1), |para| para.first_line_indent -= off); + shift.x += off; + } + } + p.xf = self.t.xf * Affine::translate(shift); + Some(()) + } +} + +/// Put each object of `items` (id, the node, its pieces) out of the document, its pieces in its +/// place, as one undo step labelled `label`, the pieces selected → their ids. +fn replace_with_pieces(s: &mut Session, label: &str, items: Vec<(NodeId, Node, Vec)>) -> Result> { + s.edit(label, |d, sel| { + let (mut made, mut places) = (vec![], vec![]); + for (id, mut node, pieces) in items { + // A light template: the pieces take its paint and attributes, not its text. + node.kind = NodeKind::Text(Box::new(TextObject::point(Point::ZERO, "", CharStyle::default()))); + let nodes: Vec = pieces + .into_iter() + .map(|mut t| { + refresh_bounds(&mut t); + let mut n = d.reid(&node); + n.kind = NodeKind::Text(Box::new(t)); + n + }) + .collect(); + made.extend(nodes.iter().map(|n| n.id)); + places.push((id, nodes)); + } + d.insert_beside(places, true)?; + sel.set(made.iter().copied()); + Ok(made.iter().map(|i| i.0).collect()) + }) +} + +fn ids_json(ids: &[NodeId]) -> Vec { + ids.iter().map(|i| i.0).collect() +} + +fn break_text(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.breakText"; + let by = match text_param(p, "by", C)?.unwrap_or("lines") { + "lines" => By::Lines, + "paragraphs" => By::Paragraphs, + "words" => By::Words, + "glyphs" => By::Glyphs, + other => return Err(bad(C, format!("`by` must be lines, paragraphs, words or glyphs, not {other:?}"))), + }; + let d = &s.doc()?.doc; + let (mut items, mut skipped, mut count) = (vec![], vec![], 0); + for id in texts_under(d, &checked_ids_or_selection(s, p, C)?)? { + let (Some(src), Some(node)) = (Source::of(d, id), d.node(id)) else { + skipped.push(id); + continue; + }; + let ranges = src.ranges(by); + count += ranges.len(); + if count > MAX_PIECES { + return Err(EngineError::Other("Too many pieces to make at once".into())); + } + let pieces: Vec = ranges.into_iter().filter_map(|r| src.piece(r, by == By::Paragraphs)).collect(); + if pieces.is_empty() { + skipped.push(id); + } else { + items.push((id, node.clone(), pieces)); + } + } + if items.is_empty() { + return Err(EngineError::Other("Select horizontal point or area type whose text fits".into())); + } + let ids = replace_with_pieces(s, "Break Text", items)?; + Ok(json!({ "ids": ids, "skipped": ids_json(&skipped) })) +} + +fn split_text(s: &mut Session, _: &Value) -> Result { + let Some((id, a, b)) = editing_range(s)? else { + return Err(EngineError::Other("Click in type with a Type tool to split it".into())); + }; + let d = &s.doc()?.doc; + let (Some(src), Some(node)) = (Source::of(d, id), d.node(id).filter(|_| d.is_editable(id))) else { + return Err(EngineError::Other("Select horizontal point or area type whose text fits".into())); + }; + let text = &src.text; + let (a, b) = (edit::floor_char(text, a.min(b)), edit::floor_char(text, a.max(b))); + let cuts: Vec<(usize, usize)> = if a == b { vec![(0, a), (a, text.len())] } else { vec![(0, a), (a, b), (b, text.len())] }; + let pieces: Vec = cuts.into_iter().filter_map(|r| src.piece(r, true)).collect(); + if pieces.len() < 2 { + return Err(EngineError::Other("Nothing to split at the cursor".into())); + } + let node = node.clone(); + let ids = replace_with_pieces(s, "Split Text at Cursor", vec![(id, node, pieces)])?; + // The text edited is gone: the pieces stay selected. + s.set_tool_option("stopEditing", &Value::Bool(true)); + Ok(json!({ "ids": ids })) +} + +/// One piece of point type Combine Point Text takes: its text with its object's scale taken into +/// its characters, and where it starts and ends along its line in the frame of its turn and shear. +struct Piece { + id: NodeId, + t: TextObject, + /// The object's turn and shear (`xf` without its move). + turn: [f64; 4], + /// Where its first character's glyph starts on its baseline, in that frame. + at: Point, + /// Where its last glyph ends along the line, in that frame. + end: f64, + /// Its first character's size. + size: f64, + /// Each character's pen position from the first's (byte, x), as it lays out alone. + pens: Vec<(usize, f64)>, +} + +impl Piece { + fn of(d: &Document, id: NodeId) -> Option<(Self, &Node)> { + let node = d.node(id)?; + let NodeKind::Text(t) = &node.kind else { return None }; + let text = t.plain_text(); + if t.vertical || !matches!(t.kind, TextKind::Point) || text.trim().is_empty() || text.contains(['\n', '\u{2028}']) { + return None; + } + let mut t = (**t).clone(); + t.scale_text_attributes(); + t.style_scale()?; + let lay = layout(&t); + let line = lay.lines.first().filter(|l| !l.rtl && lay.lines.len() == 1)?; + let first = glyph_at(&lay, 0)?; + // The first glyph of each cluster (a mark drawn over it shares its byte). + let mut seen = std::collections::HashSet::new(); + let pens = lay.glyphs.iter().filter(|g| g.len > 0 && seen.insert(g.byte)).map(|g| (g.byte, g.origin.x - first.x)).collect(); + let end = lay.glyphs.iter().map(|g| g.origin.x + g.advance).fold(first.x, f64::max) - first.x; + let [a, b, c, dd, _, _] = t.xf.as_coeffs(); + let turn = Affine::new([a, b, c, dd, 0.0, 0.0]); + let at = turn.inverse() * (t.xf * Point::new(first.x, line.baseline)); + let size = t.runs.first()?.style.size; + if !(at.x.is_finite() && at.y.is_finite() && size > 0.0) { + return None; + } + Some((Self { id, t, turn: [a, b, c, dd], at, end: at.x + end, size, pens }, node)) + } + + /// Whether `o` is turned and sheared alike. + fn turned_like(&self, o: &Piece) -> bool { + self.turn.iter().zip(&o.turn).all(|(x, y)| (x - y).abs() <= 1e-6 * x.abs().max(y.abs()).max(1.0)) + } +} + +/// Whether two objects look alike apart from their text (paint, opacity, blending, effects). +fn same_look(a: &Node, b: &Node) -> bool { + a.appearance == b.appearance + && a.opacity == b.opacity + && a.blend == b.blend + && a.isolate == b.isolate + && a.knockout == b.knockout + && a.extras == b.extras +} + +/// How wide a space of `style` is. +fn space_width(style: &CharStyle) -> f64 { + let probe = TextObject::point(Point::ZERO, " ", CharStyle { tracking: 0.0, ..style.clone() }); + layout(&probe).glyphs.first().map_or(style.size * 0.25, |g| g.advance) +} + +/// `chain` (pieces left to right on one line) as one point type object whose glyphs land where +/// theirs were, or the index of the first piece that can't join the ones before it so. +fn merge(chain: &[&Piece]) -> std::result::Result { + let Some(first) = chain.first() else { return Err(0) }; + let mut runs = vec![]; + // Each piece's first byte, and the byte range of the character before it (whose tracking sets + // the gap). + let (mut starts, mut before) = (vec![], vec![]); + let mut text = String::new(); + for (k, p) in chain.iter().enumerate() { + if let Some(prev) = k.checked_sub(1).and_then(|i| chain.get(i)) { + let starts_blank = p.t.plain_text().starts_with(char::is_whitespace); + let last = prev.t.runs.iter().rev().find(|r| !r.text.is_empty()).map(|r| r.style.clone()).unwrap_or_default(); + if !text.ends_with(char::is_whitespace) && !starts_blank && p.at.x - prev.end >= 0.5 * space_width(&last) { + runs.push(vectorcraft_doc::TextRun::new(" ", last)); + text.push(' '); + } + let c = text.chars().next_back().map_or(1, char::len_utf8); + before.push((text.len() - c, text.len())); + } + starts.push(text.len()); + // Baselines a little apart: the difference is baseline shift (up is positive). + let shift = first.at.y - p.at.y; + for r in &p.t.runs { + let mut r = r.clone(); + r.style.baseline_shift += shift; + text.push_str(&r.text); + runs.push(r); + } + } + let mut m = first.t.clone(); + m.set_all_paras(ParaStyle { justify: Justify::Left, ..first.t.para.clone() }); + let [a, b, c, d] = first.turn; + let base = Affine::new([a, b, c, d, 0.0, 0.0]) * Affine::translate(first.at.to_vec2()); + m.xf = base; + m.runs = runs; + m.ruby.clear(); + // Tracking after the character before each piece, until each lands where it was. + let targets: Vec = chain.iter().map(|p| p.at.x - first.at.x).collect(); + let mut lay = layout(&m); + for _ in 0..4 { + // A piece whose first character joins the one before it (a ligature) can't keep its place. + let xs: Vec = match starts.iter().enumerate().map(|(k, b)| glyph_starting(&lay, *b).map(|g| g.x).ok_or(k)).collect() { + Ok(xs) => xs, + Err(k) => return Err(k.max(1)), + }; + let x0 = xs.first().copied().unwrap_or(0.0); + let off: Vec = targets.iter().zip(&xs).map(|(t, x)| t - (x - x0)).collect(); + if off.iter().all(|o| o.abs() <= TOLERANCE * 0.5) { + break; + } + // Moving piece k moves those after it as far. + for (k, w) in off.windows(2).enumerate() { + let (delta, Some(&(s, e))) = (w[1] - w[0], before.get(k)) else { continue }; + if delta.abs() > 1e-9 { + edit::style_range(&mut m.runs, s, e, |st| st.tracking += delta / st.size.max(1e-6) * 1000.0); + if !TRACKING.contains(&edit::style_at(&m.runs, s).tracking) { + return Err(k + 1); + } + } + } + lay = layout(&m); + } + // Every glyph where it was, within the tolerance; else the run ends before the piece that moved. + let x0 = glyph_starting(&lay, 0).map_or(0.0, |g| g.x); + for (k, p) in chain.iter().enumerate() { + let (Some(&start), Some(&target)) = (starts.get(k), targets.get(k)) else { return Err(k.max(1)) }; + let moved = p.pens.iter().any(|(byte, x)| glyph_starting(&lay, start + byte).is_none_or(|g| (g.x - x0 - target - x).abs() > TOLERANCE)); + if moved { + return Err(k.max(1)); + } + } + m.xf = base * Affine::translate((-x0, 0.0)); + Ok(m) +} + +fn combine_text(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.combineText"; + let d = &s.doc()?.doc; + let mut pieces: Vec<(Piece, &Node)> = + texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().filter_map(|id| Piece::of(d, id)).collect(); + // Lines: pieces turned alike and looking alike, by baseline, then left to right. + pieces.sort_by(|x, y| x.0.at.y.total_cmp(&y.0.at.y).then(x.0.at.x.total_cmp(&y.0.at.x))); + let mut lines: Vec> = vec![]; + for (piece, node) in &pieces { + let fits = |l: &Vec<(&Piece, &Node)>| { + l.first() + .is_some_and(|(f, n)| f.turned_like(piece) && same_look(n, node) && (f.at.y - piece.at.y).abs() <= SAME_LINE * f.size.min(piece.size)) + }; + match lines.iter_mut().find(|l| fits(l)) { + Some(l) => l.push((piece, node)), + None => lines.push(vec![(piece, node)]), + } + } + // Runs along each line: a wide gap or an overlap starts another. + let mut chains: Vec> = vec![]; + for mut l in lines { + l.sort_by(|x, y| x.0.at.x.total_cmp(&y.0.at.x)); + let mut chain: Vec<&Piece> = vec![]; + for (piece, _) in l { + if let Some(prev) = chain.last() { + let gap = piece.at.x - prev.end; + if gap > MAX_GAP * prev.size || gap < -MAX_OVERLAP * prev.size { + chains.push(std::mem::take(&mut chain)); + } + } + chain.push(piece); + } + chains.push(chain); + } + // Each run as one object, or as several where a piece can't keep its place joined. + let mut made: Vec<(NodeId, TextObject, Vec)> = vec![]; + let mut tries = 0; + 'chains: for chain in chains { + let mut rest: &[&Piece] = &chain; + while rest.len() > 1 { + let mut upto = rest.len(); + while let Some(part) = rest.get(..upto) { + tries += part.len(); + if tries > MAX_JOIN_TRIES { + break 'chains; + } + match merge(part) { + Ok(m) => { + let gone = part.iter().skip(1).map(|p| p.id).collect(); + if let Some(first) = part.first() { + made.push((first.id, m, gone)); + } + break; + } + // A run of one stays as it is. + Err(k) if k <= 1 => { + upto = 1; + break; + } + Err(k) => upto = k, + } + } + rest = rest.get(upto.max(1)..).unwrap_or_default(); + } + } + if made.is_empty() { + return Err(EngineError::Other("Select two or more one-line point type objects on one baseline to combine".into())); + } + let combined: usize = made.iter().map(|m| m.2.len()).sum(); + let ids: Vec = made.iter().map(|m| m.0.0).collect(); + s.edit("Combine Point Text", |d, sel| { + let mut gone = vec![]; + for (id, mut t, others) in made { + refresh_bounds(&mut t); + if let Some(n) = d.node_mut(id) { + n.kind = NodeKind::Text(Box::new(t)); + } + gone.extend(others); + } + d.remove_many(&gone)?; + sel.set(ids.iter().map(|i| NodeId(*i))); + Ok(()) + })?; + Ok(json!({ "ids": ids, "combined": combined })) +} + +/// Remove the breaks `brk` in bytes `a..b` of `t`: one between two words becomes a space → how many +/// went. +fn strip_breaks(t: &mut TextObject, a: usize, b: usize, brk: char) -> usize { + let text = t.plain_text(); + let (a, b) = (edit::floor_char(&text, a), edit::floor_char(&text, b)); + let Some(range) = text.get(a..b) else { return 0 }; + let mut prev = text.get(..a).and_then(|s| s.chars().next_back()); + let mut cuts = vec![]; + for (i, c) in range.char_indices() { + let at = a + i; + if c != brk { + prev = Some(c); + continue; + } + // The next character left: past the breaks that go too. + let after = at + c.len_utf8(); + let next = text.get(after..).and_then(|rest| rest.char_indices().find(|(j, n)| *n != brk || after + j >= b).map(|(_, n)| n)); + let space = prev.is_some_and(|p| !p.is_whitespace()) && next.is_some_and(|n| !n.is_whitespace()); + if space { + prev = Some(' '); + } + cuts.push((at, space)); + } + for &(at, space) in cuts.iter().rev() { + let insert = if space { " " } else { "" }; + let end = at + brk.len_utf8(); + t.splice_paras(at, end, insert); + edit::replace_range(&mut t.runs, at, end, insert); + t.ruby_after_replace(at, end, insert.len()); + } + cuts.len() +} + +fn remove_breaks(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.removeBreaks"; + let (brk, label, none) = match text_param(p, "kind", C)? { + Some("line") => ('\u{2028}', "Remove Line Breaks", "No line breaks to remove"), + Some("paragraph") => ('\n', "Remove Paragraph Breaks", "No paragraph breaks to remove"), + Some(other) => return Err(bad(C, format!("`kind` must be \"line\" or \"paragraph\", not {other:?}"))), + None => return Err(bad(C, "missing `kind`")), + }; + // The characters a Type tool has selected, else all the text of the selected type. + let editing = if p.get("ids").is_some() { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) }; + let d = &s.doc()?.doc; + let targets: Vec<(NodeId, usize, usize)> = match editing { + Some((id, a, b)) => vec![(id, a.min(b), a.max(b))].into_iter().filter(|(id, ..)| d.is_editable(*id)).collect(), + None => texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().map(|id| (id, 0, usize::MAX)).collect(), + }; + let count: usize = targets + .iter() + .filter_map(|(id, a, b)| { + let text = text_of(d, *id)?.plain_text(); + let (a, b) = (edit::floor_char(&text, *a), edit::floor_char(&text, *b)); + Some(text.get(a..b)?.matches(brk).count()) + }) + .sum(); + if count == 0 { + return Err(EngineError::Other(none.into())); + } + if count > MAX_BREAKS { + return Err(EngineError::Other("Too many breaks to remove at once".into())); + } + let (removed, ids) = s.edit(label, |d, _| { + let (mut removed, mut ids) = (0, vec![]); + for (id, a, b) in targets { + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue }; + let n = strip_breaks(t, a, b, brk); + if n > 0 { + refresh_bounds(t); + removed += n; + ids.push(id.0); + } + } + Ok((removed, ids)) + })?; + Ok(json!({ "ids": ids, "removed": removed })) +} + +/// Horizontal point type among the objects `p` names. +fn point_texts(s: &Session, p: &Value, cmd: &str) -> Result> { + let d = &s.doc()?.doc; + let ids: Vec = texts_under(d, &checked_ids_or_selection(s, p, cmd)?)? + .into_iter() + .filter(|id| text_of(d, *id).is_some_and(|t| matches!(t.kind, TextKind::Point))) + .collect(); + if ids.is_empty() { + return Err(EngineError::Other("Select point type".into())); + } + Ok(ids) +} + +/// Replace the type objects of `changed` as one undo step labelled `label` → their ids. +fn set_texts(s: &mut Session, label: &str, changed: Vec<(NodeId, TextObject)>, reset_angle: bool) -> Result { + let ids: Vec = changed.iter().map(|c| c.0.0).collect(); + s.edit(label, |d, _| { + for (id, mut t) in changed { + refresh_bounds(&mut t); + if let Some(n) = d.node_mut(id) { + n.kind = NodeKind::Text(Box::new(t)); + if reset_angle { + n.bbox_angle = 0.0; + } + } + } + Ok(()) + })?; + Ok(json!({ "ids": ids })) +} + +fn align_in_place(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.alignInPlace"; + let justify = match text_param(p, "align", C)? { + Some("left") => Justify::Left, + Some("center") => Justify::Center, + Some("right") => Justify::Right, + Some(other) => return Err(bad(C, format!("`align` must be \"left\", \"center\" or \"right\", not {other:?}"))), + None => return Err(bad(C, "missing `align`")), + }; + let ids = point_texts(s, p, C)?; + let d = &s.doc()?.doc; + let mut changed = vec![]; + for id in ids { + let Some(t) = text_of(d, id).filter(|t| !t.vertical && t.para_styles().any(|para| para.justify != justify)) else { continue }; + let was = layout(t); + // The longest line stays where it is. + let Some((i, line)) = was.lines.iter().enumerate().max_by(|a, b| (a.1.x1 - a.1.x0).total_cmp(&(b.1.x1 - b.1.x0))) else { continue }; + let mut n = t.clone(); + n.edit_paras(None, |para| para.justify = justify); + let Some(now) = layout(&n).lines.get(i).map(|l| l.x0) else { continue }; + n.xf *= Affine::translate((line.x0 - now, 0.0)); + changed.push((id, n)); + } + if changed.is_empty() { + return Err(EngineError::Other("The point type is aligned that way already".into())); + } + set_texts(s, "Set Alignment, Keep Position", changed, false) +} + +fn reset_transform(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.resetTextTransform"; + let scale = switch_or(p, "scale", false, C)?; + let ids = point_texts(s, p, C)?; + let d = &s.doc()?.doc; + let mut changed = vec![]; + for id in ids { + let Some(t) = text_of(d, id) else { continue }; + let [a, b, c, dd, _, _] = t.xf.as_coeffs(); + // The object's own axes: its width along its baseline, and its height square to it. + let (sx, det) = (a.hypot(b), a * dd - b * c); + if !(sx > 1e-12 && sx.is_finite() && det.is_finite() && det.abs() > 1e-12) { + continue; + } + let sy = det.abs() / sx; + let unscaled = |t: &TextObject| t.runs.iter().all(|r| r.style.h_scale == 100.0); + let upright = b.abs() <= 1e-9 * sx && c.abs() <= 1e-9 * sy && a > 0.0 && dd > 0.0; + if upright && (!scale || ((sx - sy).abs() <= 1e-9 * sy && unscaled(t))) { + continue; + } + let mut n = t.clone(); + let w = if scale { + n.runs.iter_mut().for_each(|r| r.style.h_scale = 100.0); + sy + } else { + sx + }; + // The centre of the text stays where it is. + let centre = t.xf * layout(t).bounds.center(); + let turn = Affine::scale_non_uniform(w, sy); + let now = turn * layout(&n).bounds.center(); + n.xf = Affine::translate(centre - now) * turn; + changed.push((id, n)); + } + if changed.is_empty() { + return Err(EngineError::Other("The point type has no transform to reset".into())); + } + set_texts(s, "Reset Text Transform", changed, true) +} + +fn replace_fonts(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.replaceFonts"; + let default = CharStyle::default().font_family; + let wanted = text_param(p, "font", C)?.filter(|f| !f.trim().is_empty()).unwrap_or(&default); + let db = FontDb::global(); + // Any name of the family is stored as its own name. + let family = match db.resolve(wanted, "Regular") { + Some((f, m)) if m != FontMatch::Missing => f.family.clone(), + _ => return Err(bad(C, "the replacement font is not available")), + }; + let missing = |st: &CharStyle| db.resolve(&st.font_family, &st.font_style).is_none_or(|(_, m)| m == FontMatch::Missing); + let d = &s.doc()?.doc; + let mut ids = vec![]; + for l in &d.layers { + l.walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind + && t.runs.iter().any(|r| missing(&r.style)) + { + ids.push(n.id); + } + }); + } + if ids.is_empty() { + return Err(EngineError::Other("No fonts are missing".into())); + } + let runs = s.edit("Replace Missing Fonts", |d, _| { + let mut runs = 0; + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + for r in t.runs.iter_mut().filter(|r| missing(&r.style)) { + // The style the font has closest to the one the character had. + r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone()); + r.style.font_family = family.clone(); + r.style.font_version = None; + r.style.font_axes.clear(); + runs += 1; + } + edit::normalize(&mut t.runs); + refresh_bounds(t); + } + Ok(runs) + })?; + Ok(json!({ "runs": runs, "font": family })) +} diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs new file mode 100644 index 000000000..a2fa3225f --- /dev/null +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -0,0 +1,307 @@ +//! Artwork Cleanup's text tools: pieces stay where their glyphs were drawn and keep their styles, +//! each command is one undo step and refuses cleanly when there is nothing to do. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Justify, NodeId, NodeKind, TextKind, TextObject}; +use vectorcraft_geom::Point; + +use crate::{EngineError, Session}; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + s +} + +fn text(s: &mut Session, p: Value) -> NodeId { + NodeId(s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap()) +} + +fn obj(s: &Session, id: NodeId) -> TextObject { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.select(|_, sel| sel.set(ids.iter().copied())).unwrap(); +} + +fn ids(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect() +} + +/// Each drawn character of `ids` (spaces left out) with where its glyph starts on the page, in +/// the order of the objects and their text. +fn glyphs(s: &Session, ids: &[NodeId]) -> Vec<(char, Point)> { + let mut out = vec![]; + for id in ids { + let t = obj(s, *id); + let text = t.plain_text(); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let mut gs: Vec<_> = lay.glyphs.iter().filter(|g| g.len > 0).collect(); + gs.sort_by_key(|g| g.byte); + gs.dedup_by_key(|g| g.byte); + for g in gs { + let c = text[g.byte..].chars().next().unwrap(); + if !c.is_whitespace() { + out.push((c, t.xf * g.origin)); + } + } + } + out +} + +fn assert_same_places(was: &[(char, Point)], now: &[(char, Point)]) { + assert_eq!(was.iter().map(|g| g.0).collect::(), now.iter().map(|g| g.0).collect::()); + for (a, b) in was.iter().zip(now) { + assert!((a.1 - b.1).hypot() < 0.02, "{:?} moved to {:?}", a, b); + } +} + +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn doc_json(s: &Session) -> String { + serde_json::to_string(&s.doc().unwrap().doc.layers).unwrap() +} + +fn other(r: Result) -> String { + match r { + Err(EngineError::Other(m)) => m, + r => panic!("expected a refusal, got {r:?}"), + } +} + +#[test] +fn break_into_words_keeps_places_styles_and_is_one_step() { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 100, "text": "Hello big World", "size": 24})); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 6, "end": 9, "size": 36, "tracking": 50})).unwrap(); + let rotated = text(&mut s, json!({"x": 300, "y": 200, "text": "Turned words", "size": 18, "rotation": 30})); + let was = glyphs(&s, &[id, rotated]); + let before = doc_json(&s); + let steps = undo_steps(&s); + select(&mut s, &[id, rotated]); + let r = s.execute("addon.cleanup.breakText", &json!({"by": "words"})).unwrap(); + let pieces = ids(&r); + assert_eq!(pieces.len(), 5); + assert_eq!(undo_steps(&s), steps + 1); + assert_eq!(s.doc().unwrap().selection.objects.len(), 5, "the pieces are selected"); + let texts: Vec = pieces.iter().map(|p| obj(&s, *p).plain_text()).collect(); + assert_eq!(texts, ["Hello", "big", "World", "Turned", "words"]); + assert_same_places(&was, &glyphs(&s, &pieces)); + let big = obj(&s, pieces[1]); + assert!(matches!(big.kind, TextKind::Point)); + assert_eq!((big.runs[0].style.size, big.runs[0].style.tracking), (36.0, 50.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(doc_json(&s), before); +} + +#[test] +fn break_area_type_into_lines_paragraphs_and_glyphs() { + for (by, count) in [("lines", None), ("paragraphs", Some(2)), ("glyphs", None)] { + let mut s = session(); + let id = text( + &mut s, + json!({"x": 40, "y": 40, "text": "A paragraph long enough to wrap in its frame\nSecond one", "size": 14, "area": {"width": 140, "height": 200}}), + ); + let t = obj(&s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert!(lay.lines.len() >= 3, "the first paragraph wraps"); + let was = glyphs(&s, &[id]); + select(&mut s, &[id]); + let r = s.execute("addon.cleanup.breakText", &json!({"by": by})).unwrap(); + let pieces = ids(&r); + let want = count.unwrap_or(if by == "lines" { lay.lines.len() } else { was.len() }); + assert_eq!(pieces.len(), want, "{by}"); + assert_same_places(&was, &glyphs(&s, &pieces)); + if by == "paragraphs" { + // The wrapped lines end in line breaks. + assert!(obj(&s, pieces[0]).plain_text().contains('\u{2028}')); + } + } +} + +#[test] +fn break_text_refuses_what_it_cannot_break() { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap(); + assert!(other(s.execute("addon.cleanup.breakText", &json!({}))).contains("point or area type")); + let r = s.execute("addon.cleanup.breakText", &json!({"by": "sentences"})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}"); +} + +#[test] +fn combine_puts_glyph_pieces_back_together_where_they_were() { + let mut s = session(); + let id = text(&mut s, json!({"x": 50, "y": 120, "text": "Water Lily", "size": 30})); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 6, "end": 10, "fill": "#ff0000"})).unwrap(); + let was = glyphs(&s, &[id]); + select(&mut s, &[id]); + let pieces = ids(&s.execute("addon.cleanup.breakText", &json!({"by": "glyphs"})).unwrap()); + assert_eq!(pieces.len(), 9); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.combineText", &json!({})).unwrap(); + assert_eq!(undo_steps(&s), steps + 1); + let made = ids(&r); + assert_eq!((made.len(), r["combined"].as_u64()), (1, Some(8))); + let t = obj(&s, made[0]); + assert_eq!(t.plain_text(), "Water Lily", "a space goes back between the words"); + assert_same_places(&was, &glyphs(&s, &made)); + let red = t.runs.iter().find(|r| r.text.contains('L')).unwrap(); + assert_ne!(red.style.fill, t.runs[0].style.fill, "styles stay"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(pieces[1]).is_some(), "undo brings the pieces back"); +} + +#[test] +fn combine_joins_only_pieces_on_one_line_turned_alike() { + let mut s = session(); + let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20})); + let b = text(&mut s, json!({"x": 110, "y": 100, "text": "Right", "size": 20})); + let c = text(&mut s, json!({"x": 50, "y": 200, "text": "Below", "size": 20})); + let d = text(&mut s, json!({"x": 300, "y": 100, "text": "Turned", "size": 20, "rotation": 45})); + let was = glyphs(&s, &[a, b]); + select(&mut s, &[a, b, c, d]); + let r = s.execute("addon.cleanup.combineText", &json!({})).unwrap(); + assert_eq!(ids(&r), [a]); + assert_eq!(obj(&s, a).plain_text(), "Left Right"); + assert_same_places(&was, &glyphs(&s, &[a])); + let doc = &s.doc().unwrap().doc; + assert!(doc.node(b).is_none() && doc.node(c).is_some() && doc.node(d).is_some()); + select(&mut s, &[c, d]); + assert!(other(s.execute("addon.cleanup.combineText", &json!({}))).contains("combine")); +} + +#[test] +fn remove_line_and_paragraph_breaks() { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 60, "text": "one\u{2028}two \u{2028}three\nfour\n\nfive", "size": 12})); + select(&mut s, &[id]); + let before = doc_json(&s); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "line"})).unwrap(); + assert_eq!(r["removed"], 2); + assert_eq!(obj(&s, id).plain_text(), "one two three\nfour\n\nfive"); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap(); + assert_eq!(r["removed"], 3); + let t = obj(&s, id); + assert_eq!((t.plain_text().as_str(), t.paragraph_count()), ("one two three four five", 1)); + assert_eq!(other(s.execute("addon.cleanup.removeBreaks", &json!({"kind": "line"}))), "No line breaks to remove"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(doc_json(&s), before); + assert!(matches!(s.execute("addon.cleanup.removeBreaks", &json!({})), Err(EngineError::BadParams { .. }))); +} + +/// The Type tool editing `id` with bytes `anchor..caret` selected. +fn edit_text(s: &mut Session, id: NodeId, anchor: usize, caret: usize) { + s.select_tool("type", Default::default()).unwrap(); + s.set_tool_option("textSelection", &json!({"editing": id.0, "anchor": anchor, "caret": caret})); +} + +#[test] +fn remove_breaks_in_the_characters_a_type_tool_selected() { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 60, "text": "a\nb\nc", "size": 12})); + select(&mut s, &[id]); + edit_text(&mut s, id, 0, 2); + s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "a b\nc"); +} + +#[test] +fn split_at_the_cursor_and_around_a_selection() { + for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 60, "text": "Split here please", "size": 20, "justify": "center"})); + let was = glyphs(&s, &[id]); + select(&mut s, &[id]); + let r = s.execute("addon.cleanup.splitText", &json!({})); + assert!(matches!(&r, Err(EngineError::Disabled(_, why)) if why.contains("Type tool")), "{r:?}"); + edit_text(&mut s, id, anchor, caret); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.splitText", &json!({})).unwrap(); + assert_eq!(undo_steps(&s), steps + 1); + let pieces = ids(&r); + let texts: Vec = pieces.iter().map(|p| obj(&s, *p).plain_text()).collect(); + assert_eq!(texts, want); + assert_same_places(&was, &glyphs(&s, &pieces)); + assert!(s.tool_options()["editing"].is_null(), "editing ends"); + } +} + +#[test] +fn split_keeps_the_later_lines_of_a_wrapped_piece() { + let mut s = session(); + let id = text( + &mut s, + json!({"x": 40, "y": 40, "text": "First words then a long run of text that wraps in the frame", "size": 14, "area": {"width": 160, "height": 200}}), + ); + let was = glyphs(&s, &[id]); + select(&mut s, &[id]); + edit_text(&mut s, id, 6, 6); + let pieces = ids(&s.execute("addon.cleanup.splitText", &json!({})).unwrap()); + assert_eq!(pieces.len(), 2); + assert_same_places(&was, &glyphs(&s, &pieces)); +} + +#[test] +fn set_alignment_keeps_the_text_in_place() { + let mut s = session(); + let id = text(&mut s, json!({"x": 100, "y": 60, "text": "Stay put", "size": 20})); + let multi = text(&mut s, json!({"x": 100, "y": 160, "text": "Short\u{2028}The longest line", "size": 20})); + let was = glyphs(&s, &[id]); + let longest: Vec<_> = glyphs(&s, &[multi]).into_iter().skip(5).collect(); + select(&mut s, &[id, multi]); + for align in ["center", "right", "left"] { + s.execute("addon.cleanup.alignInPlace", &json!({"align": align})).unwrap(); + assert_same_places(&was, &glyphs(&s, &[id])); + assert_same_places(&longest, &glyphs(&s, &[multi]).into_iter().skip(5).collect::>()); + } + assert_eq!(obj(&s, id).para.justify, Justify::Left); + assert!(other(s.execute("addon.cleanup.alignInPlace", &json!({"align": "left"}))).contains("already")); + assert!(matches!(s.execute("addon.cleanup.alignInPlace", &json!({"align": "up"})), Err(EngineError::BadParams { .. }))); +} + +#[test] +fn reset_text_transform_takes_off_rotation_and_with_scale_the_horizontal_scale() { + let mut s = session(); + let id = text(&mut s, json!({"x": 100, "y": 100, "text": "Tilted", "size": 20, "rotation": 30})); + s.execute("text.setRangeStyle", &json!({"id": id.0, "hScale": 150})).unwrap(); + let centre = |s: &Session| { + let t = obj(s, id); + t.xf * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds.center() + }; + let was = centre(&s); + let size = obj(&s, id).effective_char_style(&obj(&s, id).runs[0].style).unwrap().size; + select(&mut s, &[id]); + s.execute("addon.cleanup.resetTextTransform", &json!({})).unwrap(); + let t = obj(&s, id); + let [_, b, c, _, _, _] = t.xf.as_coeffs(); + assert!(b.abs() < 1e-9 && c.abs() < 1e-9, "{:?}", t.xf); + assert!((centre(&s) - was).hypot() < 1e-6); + assert!((t.effective_char_style(&t.runs[0].style).unwrap().size - size).abs() < 1e-6, "the size stays"); + assert!(t.runs[0].style.h_scale > 100.0, "the horizontal scale stays"); + assert!(other(s.execute("addon.cleanup.resetTextTransform", &json!({}))).contains("no transform")); + s.execute("addon.cleanup.resetTextTransform", &json!({"scale": true})).unwrap(); + assert_eq!(obj(&s, id).runs[0].style.h_scale, 100.0); +} + +#[test] +fn replace_missing_fonts_with_the_default_or_a_chosen_font() { + let mut s = session(); + let gone = text(&mut s, json!({"x": 40, "y": 60, "text": "Lost", "size": 20, "font": "No Such Font Family", "style": "Bold"})); + let kept = text(&mut s, json!({"x": 40, "y": 120, "text": "Fine", "size": 20})); + let fine = obj(&s, kept); + let r = s.execute("addon.cleanup.replaceFonts", &json!({})).unwrap(); + assert_eq!((r["runs"].as_u64(), r["font"].as_str()), (Some(1), Some("Source Sans 3"))); + let t = obj(&s, gone); + assert_eq!((t.runs[0].style.font_family.as_str(), t.runs[0].style.font_style.as_str()), ("Source Sans 3", "Bold")); + assert_eq!(obj(&s, kept), fine); + assert_eq!(other(s.execute("addon.cleanup.replaceFonts", &json!({}))), "No fonts are missing"); + let r = s.execute("addon.cleanup.replaceFonts", &json!({"font": "No Such Font Family"})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}"); +} diff --git a/crates/engine/src/cmd/addons/pathtools.rs b/crates/engine/src/cmd/addons/pathtools.rs index 4e78680ae..b78eb0976 100644 --- a/crates/engine/src/cmd/addons/pathtools.rs +++ b/crates/engine/src/cmd/addons/pathtools.rs @@ -1,10 +1,16 @@ //! Path Precision Tools (`addon.pathtools.*`), a built-in add-on: the Anchor Eraser Brush's stroke, //! the Extend Path tool's extension, the Measure & Inspect tool's readouts, the Anchor Inspector's -//! anchor edits, Split / Add Points at Crossings and the Parametric Shape Tool's shapes ([`shape`]). +//! anchor edits, Split / Add Points at Crossings, Slide Anchor Along Path, the Corner Tool's corners +//! ([`corners`]), the Parametric Shape Tool's shapes ([`shape`]), and the Shared Edge Merge Tool's +//! merges with Keep Shared Points Together ([`shared_edge`]). //! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's //! and the UI's. +mod corners; +#[cfg(test)] +mod effect_tests; mod shape; +mod shared_edge; #[cfg(test)] mod tests; @@ -20,7 +26,6 @@ use super::super::pathops::Beside; use super::super::{ CommandSpec, bad, checked_id, checked_ids_or_selection, cmd, finite_numbers, has_doc, has_selection, number_or, switch_or, text_param, }; -use super::cleanup::PT_PER_TOLERANCE; use crate::{EngineError, Result, Session}; /// The Anchor Eraser Brush's tolerance by default (the scale Simplify Anchors (Keep Shape) uses). @@ -54,7 +59,7 @@ pub fn specs() -> Vec { "Extend Path", [], None, - "{id, subpath?: index (0), end: \"start\"|\"end\", length?: pt (negative shortens; never all of it), to?: [x, y] (instead of length: the length that brings the end nearest this point), mode?: \"straight\"|\"bezier\" (stretch the last segment's curve, no anchor added)|\"arc\" (along the circle the end curves on, at most half way round) (\"straight\")} lengthen an open path at an end the way it runs there, or shorten it back along itself, as one undo step → {id, length (pt)}", + "{id, subpath?: index (0), end: \"start\"|\"end\", length?: pt (negative shortens; never all of it), to?: [x, y] (instead of length: the length that brings the end nearest this point), mode?: \"straight\"|\"bezier\" (stretch the last segment's curve, no anchor added)|\"arc\" (along the circle the end curves on, at most half way round) (\"straight\")} lengthen an open path at an end the way it runs there, or shorten it back along itself, as one undo step; with Keep Shared Points Together on, anchors coincident with an end that moves go with it → {id, length (pt)}", has_doc, extend ), @@ -72,7 +77,7 @@ pub fn specs() -> Vec { "Set Anchor", [], None, - "{x?, y?: pt (on the page), inLength?, outLength?: pt (0 retracts), inAngle?, outAngle?: degrees counter-clockwise} move the selected anchors (with their handles) and set their handles, as one undo step: direct-selected anchors, or every anchor of a path selected whole; a smooth anchor's other handle turns with the one set → {anchors}", + "{x?, y?: pt (on the page), inLength?, outLength?: pt (0 retracts), inAngle?, outAngle?: degrees counter-clockwise} move the selected anchors (with their handles) and set their handles, as one undo step: direct-selected anchors, or every anchor of a path selected whole; a smooth anchor's other handle turns with the one set; with Keep Shared Points Together on, anchors coincident with one that moves go with it → {anchors}", has_selection, set_anchor ), @@ -139,8 +144,19 @@ pub fn specs() -> Vec { has_selection, crossings ), + cmd!( + "addon.pathtools.slideAnchor", + "Slide Anchor Along Path", + [], + None, + "{id, subpath?: index (0), anchor: index, t: -0.98..0.98 (where to: positive, a curve parameter on the segment after the anchor; negative, back from the end of the one before)} move an anchor along its path, the segments beside it refitted so the outline stays, as one undo step; ends of open paths don't slide; with Keep Shared Points Together on, anchors coincident with it go with it → {id, strayed (pt)}", + has_doc, + slide_anchor + ), ]; v.extend(shape::specs()); + v.extend(corners::specs()); + v.extend(shared_edge::specs()); v } @@ -244,7 +260,7 @@ fn erase(s: &mut Session, p: &Value) -> Result { let ids = paths_under(d, &roots); let paths: Vec<(NodeId, &PathData)> = ids.iter().filter_map(|id| Some((*id, d.node(*id)?.path_data()?))).collect(); check_size(paths.iter().map(|(_, p)| *p), C)?; - let tol = if ignore { f64::INFINITY } else { tolerance * PT_PER_TOLERANCE }; + let tol = if ignore { f64::INFINITY } else { tolerance * po::PT_PER_TOLERANCE }; let mut removed = 0; let mut changed = vec![]; for (id, path) in paths { @@ -288,8 +304,8 @@ fn edit_whole(s: &mut Session, label: &str, changed: Vec<(NodeId, PathData)>) -> // ---------- Extend Path ---------- -/// The open subpath an `extend` request names: (path id, subpath index, its data). -fn open_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, SubPath)> { +/// The subpath a request names: (path id, subpath index, its data). +fn named_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, SubPath)> { let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), cmd)?; let si = usize_opt(p, "subpath", cmd)?.unwrap_or(0); let d = &s.doc()?.doc; @@ -298,15 +314,36 @@ fn open_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, Sub } let path = d.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(cmd, "`id` is not a path"))?; let sp = path.subpaths.get(si).ok_or_else(|| bad(cmd, "no such subpath"))?; - if sp.closed || sp.anchors.len() < 2 { - return Err(bad(cmd, "the subpath has no ends to extend (it is closed or a single point)")); - } Ok((id, si, sp.clone())) } +/// Put `new` in place of the subpath `old` (from [`named_subpath`]) as one step labelled `label` +/// (none when it is the same), with what Keep Shared Points Together moves too; the path's direct +/// selection goes. +fn set_subpath(s: &mut Session, label: &str, (id, si, old): (NodeId, usize, SubPath), new: SubPath) -> Result<()> { + if new == old { + return Ok(()); + } + let mut np = s.doc()?.doc.node(id).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(id))?; + if let Some(slot) = np.subpaths.get_mut(si) { + *slot = new; + } + let shared = shared_edge::shared_points(s, [(id, &np)])?; + s.edit(label, |d, sel| { + sel.anchors.remove(&id); + sel.segments.remove(&id); + set_path(d, id, np); + shared_edge::move_shared(d, shared); + Ok(()) + }) +} + fn extend(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.pathtools.extend"; - let (id, si, sp) = open_subpath(s, p, C)?; + let (id, si, sp) = named_subpath(s, p, C)?; + if sp.closed || sp.anchors.len() < 2 { + return Err(bad(C, "the subpath has no ends to extend (it is closed or a single point)")); + } let at_start = match text_param(p, "end", C)? { Some("start") => true, Some("end") => false, @@ -328,22 +365,26 @@ fn extend(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "`length` must be a number of at most 1000000 pt either way")); } let new = pt::extend_open(&sp, at_start, length, mode).ok_or_else(|| bad(C, "the path is too short to shorten that much"))?; - if new != sp { - s.edit("Extend Path", |d, sel| { - let n = d.node(id).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(id))?; - let mut np = n; - if let Some(slot) = np.subpaths.get_mut(si) { - *slot = new; - } - sel.anchors.remove(&id); - sel.segments.remove(&id); - set_path(d, id, np); - Ok(()) - })?; - } + set_subpath(s, "Extend Path", (id, si, sp), new)?; Ok(json!({ "id": id.0, "length": length })) } +// ---------- Slide Anchor Along Path ---------- + +fn slide_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.slideAnchor"; + let (id, si, sp) = named_subpath(s, p, C)?; + let anchor = usize_opt(p, "anchor", C)?.ok_or_else(|| bad(C, "give `anchor`, the index of the anchor to slide"))?; + let t = number_or(p, "t", f64::NAN, C)?; + if t.is_nan() || t.abs() > pt::MAX_SLIDE { + return Err(bad(C, "`t` must be a number from -0.98 to 0.98")); + } + let (new, strayed) = + pt::slide_anchor(&sp, anchor, t).ok_or_else(|| bad(C, "that anchor can't slide: an end of an open path has nowhere to go"))?; + set_subpath(s, "Slide Anchor Along Path", (id, si, sp), new)?; + Ok(json!({ "id": id.0, "strayed": strayed })) +} + // ---------- Measure & Inspect ---------- fn polar(h: Option<(f64, f64)>) -> Value { @@ -355,7 +396,7 @@ fn inspect(s: &mut Session, p: &Value) -> Result { let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), C)?; let (x, y) = (number_or(p, "x", f64::NAN, C)?, number_or(p, "y", f64::NAN, C)?); let tolerance = number_or(p, "tolerance", 3.0, C)?; - if !(x.is_finite() && y.is_finite()) || !(tolerance >= 0.0 && tolerance.is_finite()) { + if !(x.is_finite() && y.is_finite() && tolerance >= 0.0 && tolerance.is_finite()) { return Err(bad(C, "give `x` and `y` (and a `tolerance` of 0 or more)")); } let path = s.doc()?.doc.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(C, "`id` is not a path"))?; @@ -423,10 +464,12 @@ fn edit_anchors(s: &mut Session, label: &str, cmd: &str, f: impl Fn(&mut SubPath } } if !changed.is_empty() { + let shared = shared_edge::shared_points(s, changed.iter().map(|(id, np)| (*id, np)))?; s.edit(label, |d, _| { for (id, np) in changed { set_path(d, id, np); } + shared_edge::move_shared(d, shared); Ok(()) })?; } diff --git a/crates/engine/src/cmd/addons/pathtools/corners.rs b/crates/engine/src/cmd/addons/pathtools/corners.rs new file mode 100644 index 000000000..464fa032b --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/corners.rs @@ -0,0 +1,144 @@ +//! The Corner Tool's commands: the app's Live Corners ([`LiveCorners`]) cut, resized and removed on +//! chosen anchors, each edit one undo step. + +use std::collections::BTreeSet; + +use serde_json::{Value, json}; +use vectorcraft_doc::{LiveCorners, NodeId, NodeKind}; +use vectorcraft_geom::shapes::CornerKind; +use vectorcraft_tools::addons::corner_tool::MAX_RADIUS; + +use super::super::super::object::recut; +use super::super::super::{CommandSpec, bad, checked_id, checked_ids_or_selection, cmd, has_doc, number_or}; +use super::{check_size, paths_under}; +use crate::{Result, Session}; + +pub(super) fn specs() -> Vec { + vec![ + cmd!( + "addon.pathtools.cornerSet", + "Set Corners", + [], + None, + "{ids?: [id…] (the selection), anchors?: [index…] (of each path's outline with no corners cut; else its direct-selected anchors, or all of them), items?: [{id, anchors?}…] (instead of ids: each path with its own anchors), radius: pt 0..1000000 (drawn no larger than fits), kind?: \"round\"|\"invertedRound\"|\"chamfer\" (each corner's own)} cut live corners at the anchors that can have one, as one undo step → {ids, corners (how many)}", + has_doc, + corner_set + ), + cmd!( + "addon.pathtools.cornerRemove", + "Remove Corners", + [], + None, + "{ids?: [id…] (the selection), anchors?: [index…] (of each path's outline with no corners cut; else its direct-selected anchors, or all of them), items?: [{id, anchors?}…] (instead of ids: each path with its own anchors)} make those live corners sharp again, as one undo step → {ids, corners (how many)}", + has_doc, + corner_remove + ), + ] +} + +/// The `anchors` of `p`. +fn anchors_param(p: &Value, cmd: &str) -> Result>> { + let Some(v) = p.get("anchors").filter(|v| !v.is_null()) else { return Ok(None) }; + let list = v.as_array().ok_or_else(|| bad(cmd, format!("`anchors` must list anchor indices (0, 1, 2…), not {v}")))?; + list.iter() + .map(|k| { + k.as_u64() + .and_then(|k| usize::try_from(k).ok()) + .ok_or_else(|| bad(cmd, format!("`anchors` must list anchor indices (0, 1, 2…), not {k}"))) + }) + .collect::>() + .map(Some) +} + +/// The corners a request names: each path with the corners (anchor indices of its uncut outline). +fn named_corners(s: &Session, p: &Value, cmd: &str) -> Result)>> { + let shared = anchors_param(p, cmd)?; + // Each path with its own `anchors`, or sharing the request's. + let mut own: Vec>> = vec![]; + let ids: Vec = match p.get("items").filter(|v| !v.is_null()) { + None => paths_under(&s.doc()?.doc, &checked_ids_or_selection(s, p, cmd)?), + Some(Value::Array(items)) => { + let mut ids = vec![]; + for it in items { + ids.push(checked_id(s, it.get("id").unwrap_or(&Value::Null), cmd)?); + own.push(anchors_param(it, cmd)?); + } + ids + } + Some(v) => return Err(bad(cmd, format!("`items` must be a list of {{id, anchors?}}, not {v}"))), + }; + let st = s.doc()?; + let d = &st.doc; + check_size(ids.iter().filter_map(|id| d.node(*id)?.path_data()), cmd)?; + let editable = d.editable(); + let mut out = vec![]; + for (i, id) in ids.iter().enumerate() { + let Some(lc) = d.node(*id).filter(|_| editable(*id)).and_then(LiveCorners::of) else { continue }; + let corners = match own.get(i).and_then(Option::as_ref).or(shared.as_ref()) { + Some(given) => { + let n = lc.base.anchor_count(); + if let Some(past) = given.iter().find(|k| **k >= n) { + return Err(bad(cmd, format!("object {id} has {n} anchors, so no anchor {past}"))); + } + given.iter().copied().filter(|k| lc.corner(*k).is_some()).collect() + } + None => st.selection.partial(*id).filter(|a| !a.is_empty()).map_or_else(|| lc.all(), |a| lc.corners_of(a)), + }; + if !corners.is_empty() { + out.push((*id, corners)); + } + } + if out.is_empty() { + return Err(bad(cmd, "choose anchors where two sides meet at an angle: only those take a corner")); + } + Ok(out) +} + +/// Cut `radius` and `kind` on `corners` as one step (none when they have them already). +fn edit_corners(s: &mut Session, label: &str, corners: Vec<(NodeId, BTreeSet)>, radius: f64, kind: Option) -> Result { + let d = &s.doc()?.doc; + let same = corners.iter().all(|(id, picked)| { + d.node(*id) + .and_then(LiveCorners::of) + .is_some_and(|c| picked.iter().all(|k| (c.drawn_radius(*k) - radius).abs() < 1e-9 && kind.is_none_or(|kd| c.kind(*k) == kd))) + }); + let out = json!({ + "ids": corners.iter().map(|(id, _)| id.0).collect::>(), + "corners": corners.iter().map(|(_, c)| c.len()).sum::(), + }); + if !same { + s.edit(label, |d, sel| { + for (id, picked) in corners { + let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { continue }; + // A cut changes the anchors, which is all a segment selection goes by. + sel.segments.remove(&id); + recut(path, live, sel.anchors.get_mut(&id), &picked, Some(radius), kind); + } + Ok(()) + })?; + } + Ok(out) +} + +fn corner_set(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.cornerSet"; + let radius = number_or(p, "radius", f64::NAN, C)?; + if !(0.0..=MAX_RADIUS).contains(&radius) { + return Err(bad(C, "`radius` must be from 0 to 1000000 pt")); + } + let kind = p + .get("kind") + .filter(|v| !v.is_null()) + .map(|v| { + serde_json::from_value::(v.clone()) + .map_err(|_| bad(C, format!("`kind` must be \"round\", \"invertedRound\" or \"chamfer\", not {v}"))) + }) + .transpose()?; + let corners = named_corners(s, p, C)?; + edit_corners(s, "Set Corners", corners, radius, kind) +} + +fn corner_remove(s: &mut Session, p: &Value) -> Result { + let corners = named_corners(s, p, "addon.pathtools.cornerRemove")?; + edit_corners(s, "Remove Corners", corners, 0.0, Some(CornerKind::Round)) +} diff --git a/crates/engine/src/cmd/addons/pathtools/effect_tests.rs b/crates/engine/src/cmd/addons/pathtools/effect_tests.rs new file mode 100644 index 000000000..3465b7279 --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/effect_tests.rs @@ -0,0 +1,186 @@ +//! Path Effects (`addon.path.*`) through the session: live on the canvas, the document's path left +//! alone, and Expand Appearance and a save keeping what they draw. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, Effect, Node, NodeId}; +use vectorcraft_geom::{BezPath, Rect}; +use vectorcraft_render::Renderer; +use vectorcraft_render::effects::{GeomContext, apply_geometry_bez, effect_catalog, new_effect, preview, scale_effect}; + +use crate::Session; + +/// A black 4 pt stroke along `anchors`, unfilled. +fn stroked(anchors: Value, closed: bool) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap()); + s.select(|_, sel| sel.set([id])).unwrap(); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 4})).unwrap(); + (s, id) +} + +fn wave() -> (Session, NodeId) { + let anchors = json!([ + {"x": 20, "y": 100}, + {"x": 60, "y": 60, "in": [40, 60], "out": [80, 60]}, + {"x": 100, "y": 100}, + {"x": 140, "y": 140}, + {"x": 180, "y": 100} + ]); + stroked(anchors, false) +} + +/// Every Path Effect, with parameters that change the wave. +fn effects() -> Vec<(&'static str, Value)> { + vec![ + ("addon.path.dashes", json!({"dash1": 14, "gap1": 6, "dash2": 3, "gap2": 6})), + ("addon.path.dashes", json!({"dash1": 14, "gap1": 6, "alignCorners": true})), + ("addon.path.addAnchors", json!({"count": 3})), + ("addon.path.addAnchors", json!({"spacing": "distance", "distance": 15, "target": "curved"})), + ("addon.path.extend", json!({"startLength": 10, "endPercent": 10, "endShape": "arc"})), + ("addon.path.extend", json!({"startLength": -10, "endLength": -20})), + ("addon.path.close", json!({})), + ("addon.path.close", json!({"mode": "smooth"})), + ("addon.path.smooth", json!({"ratio": 0.3})), + ("addon.path.retract", json!({})), + ("addon.path.reverse", json!({})), + ("addon.path.simplify", json!({"tolerance": 200, "protectCorners": false})), + ("addon.path.split", json!({"anchors": "both"})), + ] +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +fn picture_of(d: &Document) -> Vec { + let r = Renderer::new().render_region(d, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true); + (0..200).flat_map(|y| (0..200).map(move |x| (x, y))).flat_map(|(x, y)| r.pixel(x, y)).collect() +} + +fn picture(s: &Session) -> Vec { + picture_of(&s.doc().unwrap().doc) +} + +/// The outline of `n`: its path, or those of the compound path it became. +fn outline(n: &Node) -> BezPath { + let mut out = n.path_data().map(|p| p.to_bezpath()).unwrap_or_default(); + n.children().into_iter().flatten().for_each(|c| out.extend(outline(c))); + out +} + +/// How many bytes of two pictures differ by more than antialiasing noise. +fn differing(a: &[u8], b: &[u8]) -> usize { + a.iter().zip(b).filter(|(x, y)| x.abs_diff(**y) > 8).count() +} + +#[test] +fn a_path_effect_draws_live_and_leaves_the_document_path() { + let (mut s, id) = stroked(json!([{"x": 10, "y": 50}, {"x": 190, "y": 50}]), false); + let solid = picture(&s); + let path = node(&s, id).path_data().cloned(); + s.execute("effect.apply", &json!({"effect": "addon.path.dashes", "params": {"dash1": 20, "gap1": 20}})).unwrap(); + assert_eq!(node(&s, id).path_data().cloned(), path, "only the effect holds the geometry"); + let dashed = picture(&s); + let at = |pic: &[u8], x: usize| pic[(50 * 200 + x) * 4]; + assert!(at(&solid, 45) < 40 && at(&dashed, 45) > 200, "a gap: {}", at(&dashed, 45)); + assert!(at(&dashed, 15) < 40, "a dash"); + // Hiding it draws the path again. + s.execute("effect.setParams", &json!({"index": 0, "visible": false})).unwrap(); + assert_eq!(picture(&s), solid); +} + +#[test] +fn a_path_effect_moves_the_bounds_the_object_is_measured_by() { + let (mut s, id) = stroked(json!([{"x": 50, "y": 50}, {"x": 150, "y": 50}]), false); + s.execute("effect.apply", &json!({"effect": "addon.path.extend", "params": {"startLength": 0, "endLength": 30}})).unwrap(); + let drawn = preview::bounds_of(&s.doc().unwrap().doc, &[id], true).unwrap(); + assert!((drawn.x1 - 180.0).abs() < 3.0, "{drawn:?}"); + let pic = picture(&s); + assert!(pic[(50 * 200 + 170) * 4] < 40, "and draws there"); +} + +#[test] +fn expand_appearance_writes_the_outline_the_effect_draws() { + for (effect, params) in effects() { + let (mut s, id) = wave(); + s.execute("effect.apply", &json!({"effect": effect, "params": params})).unwrap(); + let n = node(&s, id); + let before = n.path_data().cloned().unwrap(); + let live = apply_geometry_bez(&n.appearance.effects, &before.to_bezpath(), before.bounds().unwrap(), &GeomContext::of(&n)); + let pic = picture(&s); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let expanded = node(&s, id); + assert!(expanded.appearance.effects.is_empty(), "{effect} {params}"); + let (out, want) = (outline(&expanded), live); + assert_eq!(out.elements().len(), want.elements().len(), "{effect} {params}"); + for (a, b) in out.elements().iter().zip(want.elements()) { + assert!(a.is_finite() && format!("{a:.4?}") == format!("{b:.4?}"), "{effect} {params}: {a:?} {b:?}"); + } + assert_eq!(differing(&pic, &picture(&s)), 0, "{effect} {params}"); + } +} + +#[test] +fn path_effects_round_trip_through_a_save_and_scale_their_distances() { + for (effect, params) in effects() { + let (mut s, id) = wave(); + s.execute("effect.apply", &json!({"effect": effect, "params": params})).unwrap(); + let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(restored.node(id).unwrap().appearance.effects, node(&s, id).appearance.effects, "{effect} {params}"); + assert_eq!(picture_of(&restored), picture(&s), "{effect} {params}"); + } + let mut e = new_effect("addon.path.dashes", &json!({"dash1": 10})).unwrap(); + scale_effect(&mut e, 2.0); + assert_eq!((e.params["dash1"].as_f64(), e.params["gap1"].as_f64()), (Some(20.0), Some(12.0))); +} + +#[test] +fn undo_takes_a_path_effect_off_in_one_step() { + let (mut s, id) = wave(); + s.execute("effect.apply", &json!({"effect": "addon.path.reverse"})).unwrap(); + assert_eq!(node(&s, id).appearance.effects.len(), 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(node(&s, id).appearance.effects.is_empty()); +} + +/// A file's effects skip the checks `effect.apply` makes: whatever they hold, the object draws and +/// expands. +#[test] +fn path_effects_with_junk_params_from_a_file_draw_and_expand() { + let junk = [ + json!(null), + json!("x"), + json!({"dash1": 1e308, "gap1": -1e308, "offset": "x", "alignCorners": 3}), + json!({"count": 1e308, "maxCount": 1e308, "distance": -1e308, "spacing": "distance", "target": []}), + json!({"startLength": 1e308, "endPercent": -1e308, "endShape": {}, "mode": 1, "ratio": "x", "handles": "shorter", "length": 1e308}), + json!({"tolerance": 1e308, "protectCorners": "no", "anchors": null, "startPercent": 1e308, "startShape": "arc"}), + ]; + let ids = effect_catalog().into_iter().map(|e| e.id).filter(|id| id.starts_with("addon.path.")); + let stack: Vec = + ids.flat_map(|id| junk.iter().map(move |params| Effect { id: id.into(), params: params.clone(), visible: true })).collect(); + let (mut s, id) = wave(); + s.edit("Junk", |d, _| { + if let Some(n) = d.node_mut(id) { + n.appearance.effects = stack; + } + Ok(()) + }) + .unwrap(); + let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false); + picture_of(&vectorcraft_format::load(&saved).unwrap()); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + assert!(node(&s, id).appearance.effects.is_empty()); +} + +#[test] +fn exports_write_the_dashes_as_paths() { + let (mut s, _) = wave(); + s.execute("effect.apply", &json!({"effect": "addon.path.dashes", "params": {"dash1": 10, "gap1": 10}})).unwrap(); + let v = s.execute("document.export", &json!({"format": "svg"})).unwrap(); + let svg = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap(); + let moves = svg.iter().filter(|c| **c == b'M').count(); + assert!(moves > 5, "the exported art holds the dashes: {moves}"); +} diff --git a/crates/engine/src/cmd/addons/pathtools/shared_edge.rs b/crates/engine/src/cmd/addons/pathtools/shared_edge.rs new file mode 100644 index 000000000..e5d61c47b --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/shared_edge.rs @@ -0,0 +1,164 @@ +//! The Shared Edge Merge Tool's command and Keep Shared Points Together: with that option on, the +//! add-on's own commands that move an anchor (Slide Anchor Along Path, Extend Path, Set Anchor) take +//! the anchors coincident with it along. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::{AnchorRef, Document, NodeId, NodeKind}; +use vectorcraft_geom::{PathData, Point, Vec2}; +use vectorcraft_pathops::addons::pathtools as pt; +use vectorcraft_tools::addons::shared_edge::{DIAMETER, DIAMETER_RANGE, ID}; + +use super::super::super::{CommandSpec, always, bad, checked_id, cmd, finite_numbers, has_doc, number_or, switch_or, text_param}; +use super::{edit_whole, set_path}; +use crate::{Result, Session}; + +#[cfg(test)] +mod tests; + +pub(super) fn specs() -> Vec { + vec![ + cmd!( + "addon.pathtools.mergeEdge", + "Merge Shared Edge", + [], + None, + "{a, b: two path ids (they needn't be selected), at: [x, y] (where), diameter?: pt 0.25..200 (20; how near `at` the edge is looked for), tolerance?: pt 0..3 (0.002; how near two anchors count as coincident), follow?: \"average\" (half way between the two)|\"a\"|\"b\" (keep that path's outline), ends?: bool (false: true makes an end even where the paths could merge)} where the paths touch at two coincident anchors, one each side of `at`, replace the stretches between them with one shared edge; stretches much longer one than the other (by over 10%) or leaving an end at over 120° aren't merged. Otherwise make an end there: two near anchors meet, and an anchor only one path has there is matched by a new one on the other. Each path stays closed or open; one undo step → {a, b, made: \"edge\"|\"end\"|\"nothing\" (merged already)}", + has_doc, + merge_edge + ), + cmd!( + "addon.pathtools.movePointsTogether", + "Keep Shared Points Together", + [], + None, + "{enabled?: bool (default: the opposite of now)} Keep Shared Points Together: whether anchors coincident (within the Shared Edge Merge Tool's `tolerance`) with one that Slide Anchor Along Path, Extend Path or Set Anchor moves move with it, on any editable path (the `together` option of tool `sharedEdge`, kept with the preferences; off by default) → {enabled}", + always, + move_points_together + ), + ] +} + +/// A path a merge may edit. +fn merge_path<'a>(d: &'a Document, id: NodeId, cmd: &str) -> Result<&'a PathData> { + if !d.is_editable(id) { + return Err(bad(cmd, "the path is locked or hidden")); + } + match d.node(id).map(|n| &n.kind) { + Some(NodeKind::Path { path, guide: false, .. }) => Ok(path), + _ => Err(bad(cmd, "`a` and `b` must be two different paths")), + } +} + +fn merge_edge(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.mergeEdge"; + let a = checked_id(s, p.get("a").unwrap_or(&Value::Null), C)?; + let b = checked_id(s, p.get("b").unwrap_or(&Value::Null), C)?; + if a == b { + return Err(bad(C, "`a` and `b` must be two different paths")); + } + let [x, y] = p.get("at").and_then(finite_numbers::<2>).ok_or_else(|| bad(C, "`at` must be [x, y]"))?; + let at = Point::new(x, y); + let diameter = number_or(p, "diameter", DIAMETER, C)?; + if !(DIAMETER_RANGE.0..=DIAMETER_RANGE.1).contains(&diameter) { + return Err(bad(C, "`diameter` must be from 0.25 to 200 pt")); + } + let tolerance = number_or(p, "tolerance", pt::COINCIDENCE, C)?; + if !(0.0..=pt::MAX_COINCIDENCE).contains(&tolerance) { + return Err(bad(C, "`tolerance` must be from 0 to 3 pt")); + } + let follow = match text_param(p, "follow", C)? { + None => pt::Follow::Average, + Some(f) => pt::Follow::parse(f).ok_or_else(|| bad(C, "`follow` must be \"average\", \"a\" or \"b\""))?, + }; + let ends = switch_or(p, "ends", false, C)?; + let d = &s.doc()?.doc; + let (pa, pb) = (merge_path(d, a, C)?, merge_path(d, b, C)?); + let radius = diameter / 2.0; + let edge = if ends { None } else { pt::stretches_at(pa, pb, at, radius, tolerance) }; + let plan = edge.or_else(|| pt::ends_at(pa, pb, at, radius)).ok_or_else(|| bad(C, "the paths have nothing to merge there"))?; + let (na, nb) = pt::apply_merge(pa, pb, &plan, follow).ok_or_else(|| bad(C, "the paths have nothing to merge there"))?; + let changed: Vec<(NodeId, PathData)> = + [(a, pa, na), (b, pb, nb)].into_iter().filter(|(_, old, new)| *old != new).map(|(id, _, new)| (id, new)).collect(); + let made = match plan { + _ if changed.is_empty() => "nothing", + pt::MergePlan::Edge { .. } => "edge", + pt::MergePlan::Ends { .. } => "end", + }; + edit_whole(s, "Merge Shared Edge", changed)?; + Ok(json!({ "a": a.0, "b": b.0, "made": made })) +} + +fn move_points_together(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.movePointsTogether"; + let now = s.tool_options_of(ID)["together"].as_bool().unwrap_or(false); + let enabled = switch_or(p, "enabled", !now, C)?; + s.set_tool_options(Some(ID), &Map::from_iter([("together".to_string(), json!(enabled))])); + Ok(json!({ "enabled": enabled })) +} + +/// Anchors to move along with others: each with how far. +pub(super) type SharedPoints = Vec<(NodeId, AnchorRef, Vec2)>; + +/// With Keep Shared Points Together on, the anchors of editable paths coincident with an anchor +/// that `changed` (paths in their new form) moves, and not moving themselves. Subpaths whose +/// anchors `changed` adds or removes move none: their anchors stay put. +pub(super) fn shared_points<'a>(s: &Session, changed: impl IntoIterator) -> Result { + let options = s.tool_options_of(ID); + if options["together"].as_bool() != Some(true) { + return Ok(vec![]); + } + let tol = options["tolerance"].as_f64().unwrap_or(pt::COINCIDENCE); + let d = &s.doc()?.doc; + let mut moved: BTreeSet<(NodeId, AnchorRef)> = BTreeSet::new(); + let mut from: Vec<(Point, Vec2)> = vec![]; + for (id, new) in changed { + let Some(old) = d.node(id).and_then(|n| n.path_data()) else { continue }; + for (si, (o, n)) in old.subpaths.iter().zip(&new.subpaths).enumerate().filter(|(_, (o, n))| o.anchors.len() == n.anchors.len()) { + for (ai, (x, y)) in o.anchors.iter().zip(&n.anchors).enumerate().filter(|(_, (x, y))| x.p != y.p) { + moved.insert((id, (si, ai))); + from.push((x.p, y.p - x.p)); + } + } + } + let mut out = vec![]; + if from.is_empty() { + return Ok(out); + } + from.sort_by(|a, b| a.0.x.total_cmp(&b.0.x)); + let editable = d.editable(); + d.walk(|n| { + let NodeKind::Path { path, guide: false, .. } = &n.kind else { return }; + if !editable(n.id) { + return; + } + for (si, ai, a) in path.anchors() { + let start = from.partition_point(|(p, _)| p.x < a.p.x - tol); + let mut near = from.iter().skip(start).take_while(|(p, _)| p.x <= a.p.x + tol); + if let Some((_, by)) = near.find(|(p, _)| p.distance(a.p) <= tol) + && !moved.contains(&(n.id, (si, ai))) + { + out.push((n.id, (si, ai), *by)); + } + } + }); + Ok(out) +} + +/// Move the anchors [`shared_points`] found; what they are on stops being a live shape. +pub(super) fn move_shared(d: &mut Document, shared: SharedPoints) { + let mut by_path: BTreeMap> = BTreeMap::new(); + for (id, at, by) in shared { + by_path.entry(id).or_default().push((at, by)); + } + for (id, anchors) in by_path { + let Some(mut np) = d.node(id).and_then(|n| n.path_data()).cloned() else { continue }; + for ((si, ai), by) in anchors { + if let Some(a) = np.anchor_mut(si, ai) { + a.translate(by); + } + } + set_path(d, id, np); + } +} diff --git a/crates/engine/src/cmd/addons/pathtools/shared_edge/tests.rs b/crates/engine/src/cmd/addons/pathtools/shared_edge/tests.rs new file mode 100644 index 000000000..3ffe2fea7 --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/shared_edge/tests.rs @@ -0,0 +1,204 @@ +//! The Shared Edge Merge Tool's command and Keep Shared Points Together: one undo step each, bad +//! params refused, the tool's click through the session, and only the add-on's commands affected. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_geom::{PathData, Point}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use crate::{Session, ViewInfo}; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +fn line(s: &mut Session, pts: &[(f64, f64)], closed: bool) -> NodeId { + let anchors: Vec = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect(); + NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap()) +} + +fn path(s: &Session, id: NodeId) -> PathData { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone() +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn points(s: &Session, id: NodeId) -> Vec { + path(s, id).anchors().map(|(_, _, a)| a.p).collect() +} + +/// Two closed shapes with a bent edge each between (100, 100) and (200, 100). +fn shapes(s: &mut Session) -> (NodeId, NodeId) { + let a = line(s, &[(100.0, 100.0), (150.0, 104.0), (200.0, 100.0), (200.0, 50.0), (100.0, 50.0)], true); + let b = line(s, &[(200.0, 100.0), (150.0, 96.0), (100.0, 100.0), (100.0, 150.0), (200.0, 150.0)], true); + (a, b) +} + +#[test] +fn merge_edge_replaces_both_stretches_as_one_undo_step() { + let mut s = session(); + let (a, b) = shapes(&mut s); + s.execute("select.none", &json!({})).unwrap(); + let (pa, pb) = (path(&s, a), path(&s, b)); + let n = steps(&s); + let r = s.execute("addon.pathtools.mergeEdge", &json!({"a": a.0, "b": b.0, "at": [125, 100]})).unwrap(); + assert_eq!(r, json!({"a": a.0, "b": b.0, "made": "edge"})); + assert_eq!(steps(&s), n + 1); + assert!(path(&s, a).subpaths[0].closed && path(&s, b).subpaths[0].closed); + assert_eq!(points(&s, a)[..3], [Point::new(100.0, 100.0), Point::new(150.0, 100.0), Point::new(200.0, 100.0)]); + assert_eq!(points(&s, b)[..3], [Point::new(200.0, 100.0), Point::new(150.0, 100.0), Point::new(100.0, 100.0)]); + // Nothing left to do: no step. + let r = s.execute("addon.pathtools.mergeEdge", &json!({"a": a.0, "b": b.0, "at": [125, 100]})).unwrap(); + assert_eq!(r["made"], json!("nothing")); + assert_eq!(steps(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((path(&s, a), path(&s, b)), (pa, pb)); +} + +#[test] +fn merge_edge_follows_the_path_asked_and_makes_ends() { + let mut s = session(); + let (a, b) = shapes(&mut s); + let pa = path(&s, a); + s.execute("addon.pathtools.mergeEdge", &json!({"a": a.0, "b": b.0, "at": [125, 100], "follow": "a"})).unwrap(); + assert_eq!(path(&s, a), pa); + assert_eq!(points(&s, b)[1], Point::new(150.0, 104.0)); + // Ends asked for: the near anchors of two lines meet, one undo step. + let c = line(&mut s, &[(10.0, 200.0), (50.0, 200.0)], false); + let d = line(&mut s, &[(54.0, 200.0), (90.0, 240.0)], false); + let n = steps(&s); + let r = s.execute("addon.pathtools.mergeEdge", &json!({"a": c.0, "b": d.0, "at": [52, 200], "ends": true})).unwrap(); + assert_eq!((r["made"].clone(), steps(&s)), (json!("end"), n + 1)); + assert_eq!((points(&s, c)[1], points(&s, d)[0]), (Point::new(52.0, 200.0), Point::new(52.0, 200.0))); +} + +#[test] +fn merge_edge_refuses_bad_params_and_locked_paths() { + let mut s = session(); + let (a, b) = shapes(&mut s); + let (i, j) = (a.0, b.0); + let n = steps(&s); + for p in [ + json!({}), + json!({"a": i, "b": i, "at": [125, 100]}), + json!({"a": i, "b": j}), + json!({"a": i, "b": j, "at": [1]}), + json!({"a": i, "b": j, "at": [1, "x"]}), + json!({"a": i, "b": j, "at": [125, 100], "diameter": 0.1}), + json!({"a": i, "b": j, "at": [125, 100], "diameter": 201}), + json!({"a": i, "b": j, "at": [125, 100], "tolerance": 3.5}), + json!({"a": i, "b": j, "at": [125, 100], "tolerance": -1}), + json!({"a": i, "b": j, "at": [125, 100], "follow": "up"}), + json!({"a": i, "b": j, "at": [125, 100], "ends": 1}), + json!({"a": i, "b": u64::MAX, "at": [125, 100]}), + json!({"a": i, "b": j, "at": [390, 290]}), + ] { + assert!(s.execute("addon.pathtools.mergeEdge", &p).is_err(), "{p}"); + } + assert_eq!(steps(&s), n); + s.execute("select.set", &json!({"ids": [i]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + assert!(s.execute("addon.pathtools.mergeEdge", &json!({"a": i, "b": j, "at": [125, 100]})).is_err()); +} + +#[test] +fn the_tool_merges_unselected_paths_on_a_click_with_shift_following_the_upper() { + let mut s = session(); + let (lower, upper) = shapes(&mut s); + s.execute("select.none", &json!({})).unwrap(); + let view = ViewInfo { smart_guides: false, ..ViewInfo::default() }; + s.select_tool("sharedEdge", view).unwrap(); + let keep = path(&s, upper); + let n = steps(&s); + let shifted = |kind| { + let mut e = PointerEvent::new(kind, 125.0, 100.0); + e.mods = Mods { shift: true, ..Mods::default() }; + e + }; + s.pointer(&shifted(PointerKind::Down), view).unwrap(); + s.pointer(&shifted(PointerKind::Up), view).unwrap(); + assert_eq!(steps(&s), n + 1); + assert_eq!(path(&s, upper), keep); + assert_eq!(points(&s, lower)[1], Point::new(150.0, 96.0)); +} + +#[test] +fn keep_shared_points_together_is_off_by_default_and_a_preference_of_the_tool() { + let mut s = session(); + assert_eq!(s.tool_options_of("sharedEdge")["together"], json!(false)); + assert_eq!(s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": true})).unwrap(), json!({"enabled": true})); + assert_eq!(s.tool_options_of("sharedEdge")["together"], json!(true), "kept with another tool active"); + assert_eq!(s.execute("addon.pathtools.movePointsTogether", &json!({})).unwrap(), json!({"enabled": false})); + assert!(s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": 1})).is_err()); +} + +#[test] +fn a_slide_takes_coincident_anchors_along_in_one_step_when_the_option_is_on() { + let mut s = session(); + let a = line(&mut s, &[(100.0, 100.0), (200.0, 100.0), (300.0, 100.0)], false); + let b = line(&mut s, &[(200.0, 100.0), (200.0, 200.0)], false); + let far = line(&mut s, &[(200.0, 101.0), (250.0, 200.0)], false); + let slide = json!({"id": a.0, "anchor": 1, "t": 0.5}); + let n = steps(&s); + s.execute("addon.pathtools.slideAnchor", &slide).unwrap(); + assert_eq!(points(&s, b)[0], Point::new(200.0, 100.0), "off: it stays"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": true})).unwrap(); + s.execute("addon.pathtools.slideAnchor", &slide).unwrap(); + assert_eq!(steps(&s), n + 1); + let moved = points(&s, a)[1]; + assert!(moved.distance(Point::new(250.0, 100.0)) < 1e-6, "{moved:?}"); + assert!(points(&s, b)[0].distance(moved) < 1e-9); + assert_eq!(points(&s, b)[1], Point::new(200.0, 200.0)); + assert_eq!(points(&s, far)[0], Point::new(200.0, 101.0), "not coincident"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(points(&s, b)[0], Point::new(200.0, 100.0)); + // A wider coincidence reaches it, and a locked path stays. + s.set_tool_options(Some("sharedEdge"), &serde_json::Map::from_iter([("tolerance".to_string(), json!(2))])); + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + s.execute("addon.pathtools.slideAnchor", &slide).unwrap(); + assert!(points(&s, far)[0].distance(Point::new(250.0, 101.0)) < 1e-6); + assert_eq!(points(&s, b)[0], Point::new(200.0, 100.0)); +} + +#[test] +fn set_anchor_and_extend_take_coincident_anchors_along_but_built_in_moves_do_not() { + let mut s = session(); + let a = line(&mut s, &[(100.0, 100.0), (200.0, 100.0)], false); + let b = line(&mut s, &[(200.0, 100.0), (200.0, 200.0)], false); + s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": true})).unwrap(); + // Set Anchor on one selected anchor. + s.select(|_, sel| { + sel.set([a]); + sel.anchors.insert(a, [(0, 1)].into_iter().collect()); + }) + .unwrap(); + let n = steps(&s); + s.execute("addon.pathtools.setAnchor", &json!({"x": 220, "y": 90})).unwrap(); + assert_eq!(steps(&s), n + 1); + assert_eq!(points(&s, b), [Point::new(220.0, 90.0), Point::new(200.0, 200.0)]); + // An edit that moves no anchor moves none. + s.execute("addon.pathtools.corner", &json!({})).unwrap(); + assert_eq!(points(&s, b)[0], Point::new(220.0, 90.0)); + // Extend Path moves an end only when it adds no anchor. + s.execute("addon.pathtools.extend", &json!({"id": a.0, "end": "end", "length": 10, "mode": "bezier"})).unwrap(); + let end = points(&s, a)[1]; + assert!(points(&s, b)[0].distance(end) < 1e-9); + let end = points(&s, b)[0]; + s.execute("addon.pathtools.extend", &json!({"id": a.0, "end": "end", "length": 10})).unwrap(); + assert_eq!(points(&s, a).len(), 3); + assert_eq!(points(&s, b)[0], end, "the end it left stays with what shares it"); + // The app's own moves are untouched by the option. + s.select(|_, sel| { + sel.set([a]); + sel.anchors.insert(a, [(0, 1)].into_iter().collect()); + }) + .unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 5})).unwrap(); + assert_eq!(points(&s, b)[0], end); +} diff --git a/crates/engine/src/cmd/addons/pathtools/tests.rs b/crates/engine/src/cmd/addons/pathtools/tests.rs index e158db5f6..52f0c7078 100644 --- a/crates/engine/src/cmd/addons/pathtools/tests.rs +++ b/crates/engine/src/cmd/addons/pathtools/tests.rs @@ -4,7 +4,8 @@ use std::collections::BTreeSet; use serde_json::{Value, json}; -use vectorcraft_doc::NodeId; +use vectorcraft_doc::{LiveCorners, LiveShape, NodeId}; +use vectorcraft_geom::shapes::CornerKind; use vectorcraft_geom::{AnchorKind, PathData, Point}; use vectorcraft_tools::{PointerEvent, PointerKind}; @@ -456,3 +457,220 @@ fn the_tool_draws_one_step_per_drag_and_arrows_change_the_count() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(shape_of(&s, id).map(|s| s.width), Some(120.0)); } + +// ---------- Corner Tool ---------- + +/// A square (100, 100)–(300, 300), plain and selected: anchors from the top left, clockwise. +fn square(s: &mut Session) -> NodeId { + let id = line(s, &[(100.0, 100.0), (300.0, 100.0), (300.0, 300.0), (100.0, 300.0)], true); + select(s, &[id]); + id +} + +fn live(s: &Session, id: NodeId) -> Option { + s.doc().unwrap().doc.node(id).unwrap().live_shape().cloned() +} + +fn drawn(s: &Session, id: NodeId, corner: usize) -> f64 { + LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).unwrap().drawn_radius(corner) +} + +#[test] +fn corner_set_cuts_live_corners_as_one_undo_step() { + let mut s = session(); + let id = square(&mut s); + let plain = path(&s, id); + let n = steps(&s); + // The selected path: all four. + let r = s.execute("addon.pathtools.cornerSet", &json!({"radius": 20, "kind": "chamfer"})).unwrap(); + assert_eq!(r, json!({"ids": [id.0], "corners": 4})); + assert_eq!((steps(&s), path(&s, id).anchor_count()), (n + 1, 8)); + let lc = LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).map(|c| (c.drawn_radius(3), c.kind(3))).unwrap(); + assert_eq!(lc, (20.0, CornerKind::Chamfer)); + // The same again changes nothing: no step. + s.execute("addon.pathtools.cornerSet", &json!({"radius": 20, "kind": "chamfer"})).unwrap(); + assert_eq!(steps(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((path(&s, id), live(&s, id)), (plain, None)); +} + +#[test] +fn corner_set_goes_by_anchors_the_selection_and_items_and_fits() { + let mut s = session(); + let id = square(&mut s); + let other = line(&mut s, &[(350.0, 10.0), (390.0, 10.0), (390.0, 50.0)], false); + // Named anchors, of each path by `items`, at once; an open end takes none. + let items = json!([{"id": id.0, "anchors": [1]}, {"id": other.0, "anchors": [0, 1]}]); + let n = steps(&s); + let r = s.execute("addon.pathtools.cornerSet", &json!({"items": items, "radius": 5})).unwrap(); + assert_eq!((r["corners"].clone(), steps(&s)), (json!(2), n + 1)); + assert_eq!((drawn(&s, id, 1), drawn(&s, id, 0), drawn(&s, other, 1)), (5.0, 0.0, 5.0)); + // `ids` with `anchors`, keeping the kind each has. + s.execute("addon.pathtools.cornerSet", &json!({"ids": [id.0], "anchors": [1], "radius": 9})).unwrap(); + assert_eq!(drawn(&s, id, 1), 9.0); + // Direct-selected anchors: only their corners, which stay selected as they gain anchors. + select_anchors(&mut s, id, &[0]); + s.execute("addon.pathtools.cornerSet", &json!({"radius": 7})).unwrap(); + assert_eq!((drawn(&s, id, 0), drawn(&s, id, 2)), (7.0, 0.0)); + let sel = s.doc().unwrap().selection.anchors[&id].clone(); + let c = LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).unwrap(); + assert_eq!(c.corners_of(&sel), BTreeSet::from([0])); + // A radius that doesn't fit is drawn at what does. + s.execute("addon.pathtools.cornerSet", &json!({"ids": [id.0], "anchors": [2], "radius": 5000})).unwrap(); + assert!(drawn(&s, id, 2) <= 200.0 - 7.0 + 1e-9, "{}", drawn(&s, id, 2)); +} + +#[test] +fn corner_remove_makes_corners_sharp_and_a_path_plain_again() { + let mut s = session(); + let id = square(&mut s); + let plain = path(&s, id); + s.execute("addon.pathtools.cornerSet", &json!({"anchors": [0, 1], "radius": 10, "kind": "invertedRound"})).unwrap(); + let n = steps(&s); + let r = s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": id.0, "anchors": [0]}]})).unwrap(); + assert_eq!((r["corners"].clone(), steps(&s), drawn(&s, id, 0), drawn(&s, id, 1)), (json!(1), n + 1, 0.0, 10.0)); + s.execute("addon.pathtools.cornerRemove", &json!({})).unwrap(); + assert_eq!((steps(&s), path(&s, id), live(&s, id)), (n + 2, plain, None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(drawn(&s, id, 1), 10.0); +} + +#[test] +fn corner_commands_refuse_bad_params() { + let mut s = session(); + let id = square(&mut s); + let smooth = circle(&mut s); + let i = id.0; + refuses( + &mut s, + "addon.pathtools.cornerSet", + &[ + json!({"ids": [i]}), + json!({"ids": [i], "radius": -1}), + json!({"ids": [i], "radius": 1e308}), + json!({"ids": [i], "radius": "big"}), + json!({"ids": [i], "radius": 5, "kind": "spiky"}), + json!({"ids": [i], "anchors": "all", "radius": 5}), + json!({"ids": [i], "anchors": [-1], "radius": 5}), + json!({"ids": [i], "anchors": [9], "radius": 5}), + json!({"ids": [u64::MAX], "radius": 5}), + json!({"items": 3, "radius": 5}), + json!({"items": [{"anchors": [0]}], "radius": 5}), + json!({"ids": [smooth.0], "radius": 5}), + ], + ); + refuses(&mut s, "addon.pathtools.cornerRemove", &[json!({"anchors": [9], "ids": [i]}), json!({"ids": [smooth.0]})]); + // Locked: left alone. + s.execute("object.lock", &json!({"ids": [i]})).ok(); + if !s.doc().unwrap().doc.is_editable(id) { + refuses(&mut s, "addon.pathtools.cornerSet", &[json!({"items": [{"id": i}], "radius": 5})]); + } +} + +#[test] +fn the_corner_tool_clicks_drags_and_removes_in_one_step_each() { + let mut s = session(); + let id = square(&mut s); + let view = ViewInfo { smart_guides: false, ..ViewInfo::default() }; + s.select_tool("cornerTool", view).unwrap(); + s.set_tool_option("radius", &json!(15)); + let n = steps(&s); + let press = |s: &mut Session, k, x, y| s.pointer(&PointerEvent::new(k, x, y), view).unwrap(); + // A click on the top right anchor. + press(&mut s, PointerKind::Down, 300.0, 100.0); + press(&mut s, PointerKind::Up, 300.0, 100.0); + assert_eq!((steps(&s), drawn(&s, id, 1)), (n + 1, 15.0)); + // A drag from the bottom right anchor, previewed, one step. + press(&mut s, PointerKind::Down, 300.0, 300.0); + press(&mut s, PointerKind::Drag, 280.0, 280.0); + assert_eq!(drawn(&s, id, 2), 20.0, "the preview shows"); + press(&mut s, PointerKind::Drag, 270.0, 270.0); + press(&mut s, PointerKind::Up, 270.0, 270.0); + assert_eq!((steps(&s), drawn(&s, id, 2)), (n + 2, 30.0)); + // A drag on the marker resizes; a double-click on it removes. + press(&mut s, PointerKind::Down, 270.0, 270.0); + press(&mut s, PointerKind::Drag, 260.0, 260.0); + press(&mut s, PointerKind::Up, 260.0, 260.0); + assert_eq!((steps(&s), drawn(&s, id, 2)), (n + 3, 40.0)); + press(&mut s, PointerKind::DoubleClick, 260.0, 260.0); + assert_eq!((steps(&s), drawn(&s, id, 2)), (n + 4, 0.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(drawn(&s, id, 2), 40.0); + // A click on a marker selects the corner and loads its values. + press(&mut s, PointerKind::Down, 285.0, 115.0); + press(&mut s, PointerKind::Up, 285.0, 115.0); + assert_eq!(steps(&s), n + 3, "selecting is no edit"); + assert_eq!(s.doc().unwrap().selection.anchors.get(&id).map(|a| a.len()), Some(2)); + // The bottom right one, resized by its marker, loads its radius. + press(&mut s, PointerKind::Down, 260.0, 260.0); + press(&mut s, PointerKind::Up, 260.0, 260.0); + assert_eq!(s.tool_options_of("cornerTool")["radius"], json!(40.0)); + // An Alt-marquee over the left side: both anchors, in one step. + let alt = vectorcraft_tools::Mods { alt: true, ..Default::default() }; + for (k, x, y) in [(PointerKind::Down, 50.0, 50.0), (PointerKind::Drag, 150.0, 350.0), (PointerKind::Up, 150.0, 350.0)] { + s.pointer(&PointerEvent::new(k, x, y).with_mods(alt), view).unwrap(); + } + assert_eq!((steps(&s), drawn(&s, id, 0), drawn(&s, id, 3)), (n + 4, 40.0, 40.0)); +} + +// ---------- Slide Anchor Along Path ---------- + +#[test] +fn slide_anchor_moves_it_along_the_path_as_one_undo_step() { + let mut s = session(); + let e = circle(&mut s); + let before = path(&s, e); + let area = kurbo::Shape::area(&before.to_bezpath()).abs(); + let want = before.subpaths[0].segment(3); + let n = steps(&s); + let r = s.execute("addon.pathtools.slideAnchor", &json!({"id": e.0, "anchor": 3, "t": 0.5})).unwrap(); + assert!(r["strayed"].as_f64().unwrap() < 0.05, "{r}"); + let after = path(&s, e); + assert_eq!((steps(&s), after.anchor_count()), (n + 1, 16)); + assert!(after.subpaths[0].anchors[3].p.distance(kurbo::ParamCurve::eval(&want, 0.5)) < 1e-9); + assert!((kurbo::Shape::area(&after.to_bezpath()).abs() / area - 1.0).abs() < 1e-3); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, e), before); + // No move, no step. + s.execute("addon.pathtools.slideAnchor", &json!({"id": e.0, "anchor": 3, "t": 0})).unwrap(); + assert_eq!(steps(&s), n); +} + +#[test] +fn slide_anchor_refuses_bad_params() { + let mut s = session(); + let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false); + let i = l.0; + refuses( + &mut s, + "addon.pathtools.slideAnchor", + &[ + json!({"id": i, "anchor": 0, "t": 0.5}), + json!({"id": i, "anchor": 2, "t": -0.5}), + json!({"id": i, "anchor": 9, "t": 0.5}), + json!({"id": i, "anchor": 1, "t": 1.5}), + json!({"id": i, "anchor": 1}), + json!({"id": i, "t": 0.5}), + json!({"id": i, "subpath": 4, "anchor": 1, "t": 0.5}), + json!({"id": u64::MAX, "anchor": 1, "t": 0.5}), + ], + ); +} + +#[test] +fn the_slide_tool_previews_and_commits_one_step() { + let mut s = session(); + let l = line(&mut s, &[(100.0, 100.0), (200.0, 100.0), (300.0, 100.0)], false); + let view = ViewInfo { smart_guides: false, ..ViewInfo::default() }; + s.select_tool("slideAnchor", view).unwrap(); + let n = steps(&s); + s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 100.0), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 120.0), view).unwrap(); + let at = |s: &Session| path(s, l).subpaths[0].anchors[1].p; + assert!(at(&s).distance(Point::new(250.0, 100.0)) < 1e-3, "the preview shows"); + s.pointer(&PointerEvent::new(PointerKind::Drag, 280.0, 90.0), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 280.0, 90.0), view).unwrap(); + assert!(steps(&s) == n + 1 && at(&s).distance(Point::new(280.0, 100.0)) < 1e-3); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(at(&s), Point::new(200.0, 100.0)); +} diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index c52b0003f..f6aecc32d 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -125,7 +125,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}", has_doc, copy_from ), @@ -840,11 +840,23 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { const C: &str = "appearance.copyFrom"; let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?); + let defaults_only = switch_or(p, "defaultsOnly", false, C)?; + if defaults_only && (reverse || append || p.get("ids").is_some()) { + return Err(bad(C, "`defaultsOnly` cannot be combined with `ids`, `reverse` or `append`")); + } let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?; - let range = if !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { None }; - let selected = match ids_param(p, "ids") { - Some(v) => outermost(&s.doc()?.doc, v), - None => selected_roots(s)?, + let range = if !defaults_only && !reverse && !append && p.get("ids").is_none() { + super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) + } else { + None + }; + let selected = if defaults_only { + vec![] + } else { + match ids_param(p, "ids") { + Some(v) => outermost(&s.doc()?.doc, v), + None => selected_roots(s)?, + } }; // Reversed, the first selected object's attributes go onto the clicked object; with nothing // selected, what the Eyedropper picked up last does (#4382). @@ -864,7 +876,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { None => s.eyedropper_sample.clone().ok_or_else(|| bad(C, "select the object to copy from"))?, }; let picked = Picked::of(&node); - if !reverse { + if !reverse && !defaults_only { s.eyedropper_sample = Some(node); } let range = range.filter(|(id, _, _)| targets.contains(id)); diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index 832219d5f..00bcc1c83 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -222,18 +222,16 @@ fn trace_params(s: &Session, c: &str, p: &Value) -> Result { Ok(params) } -/// Document swatch `w`'s solid color with the paint a trace fills with it: a global or spot color, -/// or a tint of one, linked to its swatch as `swatch_solid` (paint.rs) links it; any other color -/// unlinked. The color is the one the paint shows (a Lab spot color's CMYK equivalent when the Spot -/// Colors options use CMYK values). +/// Document swatch `w`'s displayed solid color and unlinked trace paint. A tint uses its tinted +/// color; a Lab spot color uses its CMYK equivalent when the Spot Colors options use CMYK values. fn trace_swatch_paint(d: &Document, w: &Swatch) -> Option<(Color, Paint)> { let linked = d.swatch_link(&w.name).and_then(|(base, tint)| d.tint_paint(&base, tint)); let color = linked.as_ref().and_then(Paint::color).or_else(|| w.paint.color())?; - Some((color, linked.unwrap_or_else(|| Paint::solid(color)))) + Some((color, Paint::solid(color))) } /// Document Library with `colorGroup`: the solid colors of the active document's color group -/// `name` (so named exactly, else ignoring case), each with its paint ([`trace_swatch_paint`]). +/// `name` (so named exactly, else ignoring case), each with its unlinked paint ([`trace_swatch_paint`]). /// Errors name command `c`. ([`trace_params`] has checked that the library is the document's /// swatches.) fn trace_group_colors(c: &str, s: &Session, name: &str) -> Result> { @@ -249,9 +247,7 @@ fn trace_group_colors(c: &str, s: &Session, name: &str) -> Result Result> { if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary { return Ok(vec![]); @@ -559,15 +555,11 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result = HashMap::new(); for (rgb, paint) in &library { - let chosen = by_color.entry(*rgb).or_insert(paint); - if !matches!(**chosen, Paint::Solid { swatch: Some(_), .. }) && matches!(paint, Paint::Solid { swatch: Some(_), .. }) { - *chosen = paint; - } + by_color.entry(*rgb).or_insert(paint); } let id = s.edit(label, |d, sel| { let src = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index b117b77a9..5ad8054fa 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -444,10 +444,10 @@ fn expand_pattern_fills(s: &mut Session) -> Result<()> { }) } -/// Expand the pattern fills under `root` that paint a transparent image into the tiles they paint +/// Expand the pattern fills under `root` that paint a transparent or colorized image into the tiles they paint /// ([`expand_patterns_where`]), so each tile image becomes an object of its own; the other -/// pattern fills stay. A tile image counts as -/// transparent when its key is in `see_through` (it has see-through pixels), or when it, a group +/// pattern fills stay. A tile image counts when its key is in `see_through` (it has see-through +/// pixels), when it has a colorize paint, or when it, a group /// in the tile art, the fill, the shape or a group the shape is in has opacity or a blending /// mode. Returns how many shapes changed. pub(crate) fn expand_transparent_image_patterns(d: &mut Document, root: NodeId, see_through: &BTreeSet) -> Result { @@ -473,12 +473,12 @@ fn blended(n: &Node) -> bool { n.opacity < 1.0 || n.blend != BlendMode::Normal } -/// Whether `n` is or holds a transparent image: one with see-through pixels (key in `keys`), or -/// with opacity or a blending mode of its own or of a group in `n`, or anywhere when `on`. +/// Whether `n` is or holds an image with see-through pixels (key in `keys`), a colorize paint, +/// opacity or a blending mode of its own or of a group in `n`, or anywhere when `on`. fn transparent_image(n: &Node, keys: &BTreeSet, on: bool) -> bool { let on = on || blended(n); match &n.kind { - NodeKind::Image(im) => on || keys.contains(&im.key), + NodeKind::Image(im) => on || keys.contains(&im.key) || n.extras().colorize.is_some(), _ => n.children().is_some_and(|ch| ch.iter().any(|c| transparent_image(c, keys, on))), } } diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index 6a87b1e9e..0e53a5a18 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -161,9 +161,8 @@ fn expand_appearances(d: &mut Document) { } } -/// Expand every layer's pattern fills that paint a transparent image into their tiles, so those -/// images flatten like any other transparent image. The writer would otherwise draw them opaque -/// over white. +/// Expand every layer's pattern fills that paint an image with see-through pixels, a colorize paint, +/// opacity or blending into their tiles, so those images can be flattened. fn expand_image_patterns(d: &mut Document) -> Result<()> { let keys = super::super::flatten::see_through_images(d); let layers: Vec = d.layers.iter().map(|l| l.id).collect(); diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 518950491..cd8e0c27c 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -56,6 +56,15 @@ pub fn specs() -> Vec { has_selection_outside_isolation, |s, _| other_layers(s, true) ), + cmd!( + "object.lock.unselected", + "Unselected Artwork", + ["Object", "Lock"], + Some("Cmd+Alt+Shift+2"), + "{} lock unselected artwork in one undo step, including unselected members of partly selected groups, clipping groups and compound paths, and guides while Lock Guides is on; hidden art and art on locked layers stay unlocked; a selected layer's art counts as selected; in isolation, only art inside the isolated object is affected; object.unlockAll unlocks locked art document-wide → {count}", + has_selection, + |s, _| unselected_art(s, true) + ), cmd!( "object.hide.above", "All Artwork Above", @@ -81,7 +90,7 @@ pub fn specs() -> Vec { Some("Cmd+Alt+Shift+3"), "{} hide, in one undo step, every object select.all takes that is not selected and holds no selected object; in a group that holds one it hides the other members one by one, and a clip group, compound path or other object that holds one stays as it is; the objects of a selected layer count as selected; while Lock Guides is on, guide paths count as selected and stay as they are; object.showAll shows them again; disabled in isolation mode → {count}", has_selection_outside_isolation, - hide_unselected + |s, _| unselected_art(s, false) ), cmd!( "object.transformEach", @@ -321,15 +330,24 @@ fn other_layers(s: &mut Session, lock: bool) -> Result { Ok(json!({ "count": n })) } -/// The objects [`super::select::walk_selectable`] visits that are not among the selected objects `roots` and hold none of -/// them. The objects of a selected layer count as selected. Guide paths are left out while `guides_locked`, and so are -/// the objects that hold one. The walk goes into a plain group that holds a selected object or such a guide path; a -/// clip group, a Shaper group, a compound path and every other object that holds one is left out with its members. -fn unselected(d: &vectorcraft_doc::Document, iso: Option, selection: &[NodeId], roots: &[NodeId], guides_locked: bool) -> Vec { +/// Returns the objects [`super::select::walk_selectable`] visits that are not among the selected objects `roots` and +/// hold none of them. A selected layer's objects count as selected. The walk goes into a plain group, but not a Shaper +/// group, that holds a selected object. With `lock`, it also goes into a clip group or compound path that holds one, +/// and includes guide paths while `guides_locked`. Without `lock`, guide paths and the objects that hold one are left +/// out while `guides_locked`; a clip group, Shaper group, compound path and every other object that holds a selected +/// object or such a guide are left out with their members. +fn unselected( + d: &vectorcraft_doc::Document, + iso: Option, + selection: &[NodeId], + roots: &[NodeId], + guides_locked: bool, + lock: bool, +) -> Vec { let mut roots: HashSet = roots.iter().copied().collect(); roots.extend(selection.iter().filter_map(|id| d.node(*id)).filter(|n| n.is_layer()).flat_map(|l| l.layer_art(true))); let mut kept: Vec = roots.iter().copied().collect(); - if guides_locked { + if guides_locked && !lock { super::select::walk_selectable(d, iso, |n| { if matches!(n.kind, NodeKind::Path { guide: true, .. }) { kept.push(n.id); @@ -344,24 +362,30 @@ fn unselected(d: &vectorcraft_doc::Document, iso: Option, selection: &[N out.push(n.id); return false; } - !roots.contains(&n.id) && matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.extras().shaper.is_none() + !roots.contains(&n.id) + && (matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.extras().shaper.is_none() + || lock && (matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Compound { .. }))) }); out } -fn hide_unselected(s: &mut Session, _: &Value) -> Result { +fn unselected_art(s: &mut Session, lock: bool) -> Result { let roots = selected_roots_to_transform(s)?; let guides_locked = s.menu.guides_locked; let st = s.doc()?; - let ids = unselected(&st.doc, st.isolation, &st.selection.objects, &roots, guides_locked); - // With nothing to hide, the document and its undo history stay as they are. + let ids = unselected(&st.doc, st.isolation, &st.selection.objects, &roots, guides_locked, lock); + // With nothing to lock (Lock) or hide (Hide), the document and its undo history stay as they are. if !ids.is_empty() { - s.edit("Hide", |d, _| { - // Hiding moves no node in the tree, so the paths found up front stay right. + s.edit(if lock { "Lock" } else { "Hide" }, |d, _| { + // Locking and hiding move no node in the tree, so the paths found up front stay right. let paths = d.index_paths(ids.iter().copied()); for id in &ids { if let Some(n) = paths.get(id).and_then(|p| d.node_mut_at(p)) { - n.visible = false; + if lock { + n.locked = true; + } else { + n.visible = false; + } } } Ok(()) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index a2c37a09a..4839a0ac4 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -35,4 +35,5 @@ pub(crate) use arrange::group_nodes; pub(crate) use bounds::bounds_rewrite; pub use commands::specs; pub(super) use compound::{as_clipping_path, make_clipping_path, release_clip}; +pub(super) use shape::recut; pub(crate) use transform::{apply_transform, preview_copies, scaling, transform_node, transform_patterns, transforms_objects}; diff --git a/crates/engine/src/cmd/object/shape.rs b/crates/engine/src/cmd/object/shape.rs index c035a163d..eddc05763 100644 --- a/crates/engine/src/cmd/object/shape.rs +++ b/crates/engine/src/cmd/object/shape.rs @@ -1,3 +1,6 @@ +use vectorcraft_doc::AnchorRef; +use vectorcraft_geom::PathData; + use super::spans::Span; use super::{ BTreeSet, CornerKind, EngineError, LiveCorners, LiveShape, NodeId, NodeKind, Result, Session, Value, bad, check_numbers, check_switches, @@ -249,17 +252,29 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result { Some(k) => k.clone(), None => c.picked(partial.as_deref()), }; - let (selected, layout) = (partial.as_deref().map(|a| c.corners_of(a)), c.sources().to_vec()); - set_corners(path, live, &picked, radius, kind); - // Direct-Selected corners stay selected as they gain or lose anchors. - if let (Some(anchors), Some(selected)) = (partial, selected) - && let Some(c) = LiveCorners::new(path, live.as_deref()) - && c.sources() != layout - { - *anchors = c.anchors_of(&selected); - } + recut(path, live, partial, &picked, radius, kind); } Ok(()) })?; ok() } + +/// [`set_corners`] with the Direct-Selected corners (`partial`, the path's selected anchors) +/// staying selected as they gain or lose anchors. +pub(in crate::cmd) fn recut( + path: &mut PathData, + live: &mut Option>, + partial: Option<&mut BTreeSet>, + picked: &BTreeSet, + radius: Option, + kind: Option, +) { + let before = LiveCorners::new(path, live.as_deref()).map(|c| (partial.as_deref().map(|a| c.corners_of(a)), c.sources().to_vec())); + set_corners(path, live, picked, radius, kind); + if let (Some(anchors), Some((Some(selected), layout))) = (partial, before) + && let Some(c) = LiveCorners::new(path, live.as_deref()) + && c.sources() != layout + { + *anchors = c.anchors_of(&selected); + } +} diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 3eba9adb6..9c2feaedc 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -84,7 +84,7 @@ pub fn specs() -> Vec { "Step Type", [], None, - "{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}", + "{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}", has_doc, type_step ), @@ -1133,7 +1133,7 @@ fn type_step(s: &mut Session, p: &Value) -> Result { /// Cmd/Ctrl+Alt+Q: tracking 0 and kerning Auto (0 in the Character panel) in the type objects in /// `ids`, else in the Type tool's selected text, else in every selected type object. At a caret, -/// only the kerning of the character before it changes, as Alt+←/→ kerns there. +/// only the kerning of the character before it changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns there. fn reset_tracking(s: &mut Session, p: &Value) -> Result { let ranges = type_ranges(s, p, "type.resetTracking", |text, a| Some((edit::prev_char(text, a), a)))?; if !ranges.is_empty() { diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 579dda124..9e4969e27 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Sample Color", [], None, - "{color, stroke?: bool (default: whichever proxy is active), ids?, stop?: index} sample a colour into the active fill/stroke (and the selection); with `stop`, recolour that stop of the gradient behind the proxy instead (paint.editGradient); in a CMYK document an RGB color is stored as CMYK through the color settings, as paint.setFill stores it", + "{color, stroke?: bool (default: whichever proxy is active), ids?, stop?: index, clearStroke?: bool} sample a color into the active fill/stroke (and the selection; `ids: []` changes only the paint default); `clearStroke: true` requires `stroke: false`, `ids: []` and no `stop`, and clears the default stroke after sampling an image into the fill; with `stop`, recolor that stop of the gradient behind the proxy instead (paint.editGradient); in a CMYK document an RGB color is stored as CMYK through the color settings, as paint.setFill stores it", has_doc, sample_color ), @@ -343,7 +343,11 @@ fn eyedropper_options(s: &mut Session, p: &Value) -> Result { fn sample_color(s: &mut Session, p: &Value) -> Result { const C: &str = "paint.sampleColor"; - let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active); + let stroke = super::switch_or(p, "stroke", !s.fill_active, C)?; + let clear_stroke = super::switch_or(p, "clearStroke", false, C)?; + if clear_stroke && (stroke || p.get("stroke") != Some(&Value::Bool(false)) || p.get("ids") != Some(&json!([])) || p.get("stop").is_some()) { + return Err(bad(C, "`clearStroke` requires `stroke: false`, `ids: []` and no `stop`")); + } let c = p.get("color").ok_or_else(|| bad(C, "missing color"))?; let (id, mut q) = match p.get("stop") { None | Some(Value::Null) => (if stroke { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": c })), @@ -364,7 +368,11 @@ fn sample_color(s: &mut Session, p: &Value) -> Result { q["ids"] = ids.clone(); } let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; - (spec.run)(s, &q) + let result = (spec.run)(s, &q)?; + if clear_stroke { + s.paint.stroke = Paint::None; + } + Ok(result) } // ---------- artboards ---------- diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 2069e0674..bd9bd02a2 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2316,6 +2316,88 @@ fn live_paint_bucket_tool_fills_face() { assert_eq!(s.doc().unwrap().selection.objects, vec![f.id]); } +#[test] +fn live_paint_bucket_alt_sample_paints_next_face_without_changing_selection() { + let mut s = session(); + let g = live(&mut s); + let src = rect(&mut s, 300.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#0000ff"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 12})).unwrap(); + let dst = rect(&mut s, 500.0, 0.0, 50.0, 50.0); + s.execute("paint.default", &json!({})).unwrap(); + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone()); + let face_before = vectorcraft_tools::builder::face_at(&node(&s, g), Point::new(125.0, 50.0)).unwrap().appearance.fill_paint(); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 350.0, 50.0).with_mods(Mods { alt: true, ..Default::default() }), v).unwrap(); + assert_eq!(s.paint.fill.color(), Some(Color::rgb8(255, 0, 0))); + assert_eq!(s.paint.stroke.color(), Some(Color::rgb8(0, 0, 255))); + assert_eq!(s.paint.stroke_width, 12.0); + assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone()), before); + assert_eq!(node(&s, dst).appearance.fill_paint().color(), Some(Color::WHITE)); + assert_eq!(node(&s, src).appearance.fill_paint().color(), Some(Color::rgb8(255, 0, 0))); + assert_eq!(vectorcraft_tools::builder::face_at(&node(&s, g), Point::new(125.0, 50.0)).unwrap().appearance.fill_paint(), face_before); + s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 50.0), v).unwrap(); + assert_eq!( + vectorcraft_tools::builder::face_at(&node(&s, g), Point::new(125.0, 50.0)).unwrap().appearance.fill_paint().color(), + Some(Color::rgb8(255, 0, 0)) + ); +} + +#[test] +fn live_paint_bucket_alt_image_samples_fill_and_clears_stroke() { + let mut s = session(); + let img = image::RgbaImage::from_raw(1, 1, vec![0, 255, 0, 255]).unwrap(); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.edit("Place", |d, _| { + d.images.insert("px".into(), ImageBlob::png(png)); + let id = d.alloc_id(); + let im = ImageObject { + key: "px".into(), + width: 1, + height: 1, + xf: Affine::translate((300.0, 0.0)) * Affine::scale(100.0), + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im)))).map_err(|e| EngineError::Other(e.to_string())) + }) + .unwrap(); + let dst = rect(&mut s, 500.0, 0.0, 50.0, 50.0); + s.execute("paint.setStroke", &json!({"color": "#0000ff"})).unwrap(); + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone()); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 350.0, 50.0).with_mods(Mods { alt: true, ..Default::default() }), v).unwrap(); + assert_eq!(s.paint.fill.color(), Some(Color::rgb8(0, 255, 0))); + assert!(s.paint.stroke.is_none()); + assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone()), before); + assert_eq!(node(&s, dst).appearance.stroke_paint().color(), Some(Color::rgb8(0, 0, 255))); +} + +#[test] +fn live_paint_bucket_alt_shift_gradient_samples_active_stroke_only() { + let mut s = session(); + let src = rect(&mut s, 300.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}})).unwrap(); + let dst = rect(&mut s, 500.0, 0.0, 50.0, 50.0); + s.execute("paint.setStroke", &json!({"color": "#ffffff"})).unwrap(); + let fill = s.paint.fill.clone(); + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone()); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 350.0, 50.0).with_mods(Mods { alt: true, shift: true, ..Default::default() }), v).unwrap(); + let sampled = s.paint.stroke.color().unwrap().to_rgb(); + assert!(sampled[0] > 0.0 && sampled[2] > 0.0 && sampled[1] < 0.01, "{sampled:?}"); + assert_eq!(s.paint.fill, fill); + assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone()), before); + assert_eq!(node(&s, dst).appearance.stroke_paint().color(), Some(Color::WHITE)); + assert!(matches!(node(&s, src).appearance.fill_paint(), Paint::Gradient(_))); +} + #[test] fn live_paint_selection_marquee_selects_what_it_touches() { let mut s = session(); @@ -3106,7 +3188,7 @@ fn image_trace_document_library_uses_a_color_group() { } #[test] -fn image_trace_document_library_links_fills_to_global_swatches() { +fn image_trace_document_library_fills_are_unlinked() { let mut s = session(); // A global color, a tint of another global color and a process color. s.execute("swatch.new", &json!({"name": "Ink", "color": "#c82828", "global": true})).unwrap(); @@ -3126,7 +3208,7 @@ fn image_trace_document_library_links_fills_to_global_swatches() { }); let img = add_image(&mut s, &r, Affine::IDENTITY); let params = json!({"mode": "color", "palette": "documentLibrary", "library": "document", "colors": 3}); - // Each fill as (its color, the swatch it links to, its tint), one per color. + // Each fill as (its color, its swatch link, its tint), one per color. let fills = |s: &Session, id: &Value| { let mut v: Vec<(String, Option, f32)> = node(s, NodeId(id.as_u64().unwrap())) .children() @@ -3148,27 +3230,26 @@ fn image_trace_document_library_links_fills_to_global_swatches() { let res = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap(); let got = fills(&s, &res["id"]); assert_eq!(got.len(), 3, "{got:?}"); - assert!(got.contains(&fill("#c82828", Some("Ink"), 1.0)), "{got:?}"); - assert!(got.contains(&fill(&tint, Some("Sky"), 0.5)), "{got:?}"); + assert!(got.contains(&fill("#c82828", None, 1.0)), "{got:?}"); + assert!(got.contains(&fill(&tint, None, 1.0)), "{got:?}"); assert!(got.contains(&fill("#28a03c", None, 1.0)), "{got:?}"); - // Editing the global swatches recolors the trace: the tint at its own tint. + // Editing the global swatches leaves the traced colors unchanged. s.execute("swatch.edit", &json!({"name": "Ink", "color": "#ff8800"})).unwrap(); s.execute("swatch.edit", &json!({"name": "Sky", "color": "#000000"})).unwrap(); - let tint = hex_of(&mut s, "Sky 50%"); let got = fills(&s, &res["id"]); - assert!(got.contains(&fill("#ff8800", Some("Ink"), 1.0)), "{got:?}"); - assert!(got.contains(&fill(&tint, Some("Sky"), 0.5)), "{got:?}"); + assert!(got.contains(&fill("#c82828", None, 1.0)), "{got:?}"); + assert!(got.contains(&fill(&tint, None, 1.0)), "{got:?}"); assert!(got.contains(&fill("#28a03c", None, 1.0)), "{got:?}"); for _ in 0..3 { s.execute("edit.undo", &json!({})).unwrap(); } - // A color group's global swatches link too. + // A color group's global swatches also fill without links. s.execute("swatch.newGroup", &json!({"name": "Brand", "swatches": ["Ink", "Leaf"]})).unwrap(); let mut group = params.clone(); group["colorGroup"] = json!("Brand"); let res = s.execute("imageTrace.makeAndExpand", &json!({"id": img.0, "params": group})).unwrap(); let got = fills(&s, &res["id"]); - assert!(got.contains(&fill("#c82828", Some("Ink"), 1.0)) && got.contains(&fill("#28a03c", None, 1.0)), "{got:?}"); + assert!(got.contains(&fill("#c82828", None, 1.0)) && got.contains(&fill("#28a03c", None, 1.0)), "{got:?}"); s.execute("edit.undo", &json!({})).unwrap(); // A swatch library's colors stay unlinked. let mut web = params.clone(); @@ -3179,7 +3260,7 @@ fn image_trace_document_library_links_fills_to_global_swatches() { } #[test] -fn image_trace_document_library_links_to_a_global_swatch_after_a_process_swatch_of_its_color() { +fn image_trace_document_library_same_color_stays_unlinked_in_swatch_order() { let mut s = session(); // A process swatch, then two global swatches, all of one color. s.execute("swatch.new", &json!({"name": "Red", "color": "#c82828"})).unwrap(); @@ -3204,15 +3285,43 @@ fn image_trace_document_library_links_to_a_global_swatch_after_a_process_swatch_ let red = Color::rgb8(200, 40, 40); let reds: Vec<&Paint> = got.iter().filter(|p| p.color().map(|c| c.to_rgba8(1.0)) == Some(red.to_rgba8(1.0))).collect(); assert!(!reds.is_empty(), "{got:?}"); - assert!(reds.iter().all(|p| matches!(p, Paint::Solid { swatch: Some(w), .. } if w == "Ink")), "{got:?}"); + assert!(reds.iter().all(|p| matches!(p, Paint::Solid { swatch: None, .. })), "{got:?}"); let tints: Vec<&Paint> = got.iter().filter(|p| p.color().map(|c| c.to_rgba8(1.0)) == Some([tr, tg, tb, 255])).collect(); assert!(!tints.is_empty(), "{got:?}"); - assert!(tints.iter().all(|p| matches!(p, Paint::Solid { swatch: Some(w), tint, .. } if w == "Sky" && *tint == 0.5)), "{got:?}"); - // Editing the global swatch recolors the trace. + assert!(tints.iter().all(|p| matches!(p, Paint::Solid { swatch: None, .. })), "{got:?}"); + // Editing the global swatch leaves the trace unchanged. s.execute("swatch.edit", &json!({"name": "Ink", "color": "#ff8800"})).unwrap(); let g = node(&s, NodeId(res["id"].as_u64().unwrap())); let orange = Color::rgb8(255, 136, 0).to_rgba8(1.0); - assert!(g.children().unwrap().iter().any(|n| n.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) == Some(orange))); + assert!(!g.children().unwrap().iter().any(|n| n.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) == Some(orange))); + assert_eq!(g.children().unwrap().iter().map(|n| n.appearance.fill_paint()).collect::>(), got); +} + +#[test] +fn image_trace_document_library_same_color_is_unlinked_in_both_swatch_orders() { + let trace_fill = |global_first: bool| { + let mut s = session(); + for global in [global_first, !global_first] { + s.execute("swatch.new", &json!({"name": if global { "Ink" } else { "Red" }, "color": "#c82828", "global": global})).unwrap(); + } + let r = vectorcraft_trace::Raster::from_fn(60, 60, |_, _| [200, 40, 40, 255]); + add_image(&mut s, &r, Affine::IDENTITY); + let res = s + .execute( + "imageTrace.makeAndExpand", + &json!({"params": {"mode": "color", "palette": "documentLibrary", "library": "document", "colors": 2}}), + ) + .unwrap(); + let g = node(&s, NodeId(res["id"].as_u64().unwrap())); + let fills: Vec = g.children().unwrap().iter().map(|n| n.appearance.fill_paint()).collect(); + assert!(!fills.is_empty()); + assert!(fills.iter().all(|p| matches!(p, Paint::Solid { swatch: None, .. })), "{fills:?}"); + fills + }; + let process_first = trace_fill(false); + let global_first = trace_fill(true); + assert_eq!(process_first, global_first); + assert!(process_first.iter().all(|p| p.color() == Some(Color::rgb8(200, 40, 40)))); } #[test] @@ -3255,7 +3364,7 @@ fn image_trace_document_library_matches_a_lab_spot_by_its_cmyk_equivalent() { let res = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap(); let g = node(&s, NodeId(res["id"].as_u64().unwrap())); let got: Vec = g.children().unwrap().iter().map(|n| n.appearance.fill_paint()).collect(); - let spot = Paint::Solid { color: shown, swatch: Some("Lab Ink".into()), tint: 1.0 }; + let spot = Paint::solid(shown); assert!(got.contains(&spot), "{got:?}"); assert!(!got.iter().any(|p| p.color() == Some(process)), "{got:?}"); } diff --git a/crates/engine/src/tests/eyedropper.rs b/crates/engine/src/tests/eyedropper.rs index a17cc1bb0..786c492da 100644 --- a/crates/engine/src/tests/eyedropper.rs +++ b/crates/engine/src/tests/eyedropper.rs @@ -68,6 +68,113 @@ fn picking_up_without_weight_keeps_the_target_weight() { assert!(s.execute("appearance.copyFrom", &json!({"source": src.0, "pickUp": {"nope": true}})).is_err()); } +#[test] +fn bucket_attribute_sample_changes_only_defaults_and_respects_options() { + let mut s = session(); + let (src, dst) = source_and_target(&mut s); + let art = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let undo = undo_len(&s); + s.execute("eyedropper.setOptions", &json!({"pickUp": {"appearance": {"stroke": {"weight": false}}}})).unwrap(); + let width = s.paint.stroke_width; + s.execute("appearance.copyFrom", &json!({"source": src.0, "defaultsOnly": true})).unwrap(); + assert_eq!(s.paint.fill.color().unwrap().to_hex(), "#ff0000"); + assert_eq!(s.paint.stroke.color().unwrap().to_hex(), "#0000ff"); + assert_eq!(s.paint.stroke_width, width); + assert_eq!(s.doc().unwrap().doc, art); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(undo_len(&s), undo); + assert_eq!(node(&s, dst).appearance.fill_paint().color().unwrap().to_hex(), "#ffffff"); + for extra in [json!({"ids": []}), json!({"reverse": true}), json!({"append": true})] { + let mut p = json!({"source": src.0, "defaultsOnly": true}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + assert!(s.execute("appearance.copyFrom", &p).is_err()); + } + assert!(s.execute("appearance.copyFrom", &json!({"source": src.0, "defaultsOnly": "yes"})).is_err()); + assert_eq!(s.doc().unwrap().doc, art); + assert_eq!(undo_len(&s), undo); +} + +#[test] +fn bucket_attribute_sample_does_not_replace_last_eyedropper_sample() { + let mut s = session(); + let (src, dst) = source_and_target(&mut s); + s.execute("appearance.copyFrom", &json!({"source": src.0, "defaultsOnly": true})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + assert!(s.execute("appearance.copyFrom", &json!({"source": dst.0, "reverse": true})).is_err()); +} + +#[test] +fn bucket_attribute_sample_does_not_commit_highlighted_type() { + let mut s = session(); + s.prefs.placeholder_text = false; + let src = rect(&mut s, 300.0); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let view = ViewInfo::default(); + s.select_tool("type", view).unwrap(); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 20.0, 300.0), view).unwrap(); + } + let dst = s.doc().unwrap().selection.objects[0]; + s.tool_text("ABCD", view).unwrap(); + s.set_tool_option("select", &json!({"start": 0, "end": 2})); + let before = node(&s, dst); + let undo = undo_len(&s); + s.execute("appearance.copyFrom", &json!({"source": src.0, "defaultsOnly": true})).unwrap(); + assert_eq!(node(&s, dst), before); + assert_eq!(undo_len(&s), undo, "sampling does not commit pending typing"); + assert_eq!(s.paint.fill.color().unwrap().to_hex(), "#ff0000"); +} + +#[test] +fn bucket_color_sample_changes_only_defaults_and_supplies_next_fill() { + let mut s = session(); + let src = rect(&mut s, 300.0); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let dst = rect(&mut s, 10.0); + s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [dst.0]})).unwrap(); + let dst_before = node(&s, dst); + let art = s.doc().unwrap().doc.clone(); + let undo = undo_len(&s); + s.execute("paint.sampleColor", &json!({"color": "#00ff00", "ids": []})).unwrap(); + assert_eq!(s.paint.fill.color().unwrap().to_hex(), "#00ff00"); + assert_eq!(node(&s, dst), dst_before); + assert_eq!(s.doc().unwrap().doc, art); + assert_eq!(undo_len(&s), undo); + s.execute("appearance.copyFrom", &json!({"source": src.0, "defaultsOnly": true})).unwrap(); + assert_eq!(node(&s, dst), dst_before); + assert_eq!(undo_len(&s), undo); + let r = s.execute("livePaint.fill", &json!({"ids": [dst.0], "point": [35, 35]})).unwrap(); + let face = NodeId(r["face"].as_u64().unwrap()); + assert_eq!(node(&s, face).appearance.fill_paint().color().unwrap().to_hex(), "#ff0000"); +} + +#[test] +fn image_color_sample_sets_only_fill_default_and_clears_stroke() { + let mut s = session(); + let dst = rect(&mut s, 10.0); + s.execute("paint.setStroke", &json!({"color": "#0000ff"})).unwrap(); + let before = (node(&s, dst), s.doc().unwrap().selection.clone()); + s.execute("paint.sampleColor", &json!({"color": "#00ff00", "stroke": false, "ids": [], "clearStroke": true})).unwrap(); + assert_eq!(s.paint.fill.color(), Some(Color::rgb8(0, 255, 0))); + assert!(s.paint.stroke.is_none()); + assert_eq!((node(&s, dst), s.doc().unwrap().selection.clone()), before); + let state = (s.paint.clone(), node(&s, dst)); + for p in [ + json!({"color": "#ffffff", "stroke": true, "ids": [], "clearStroke": true}), + json!({"color": "#ffffff", "ids": [], "clearStroke": true}), + json!({"color": "#ffffff", "stroke": false, "clearStroke": true}), + json!({"color": "#ffffff", "stroke": false, "ids": [dst.0], "clearStroke": true}), + json!({"color": "#ffffff", "stroke": false, "ids": [], "stop": 0, "clearStroke": true}), + json!({"color": "#ffffff", "stroke": false, "ids": [], "clearStroke": "yes"}), + json!({"color": "#ffffff", "stroke": "no", "ids": [], "clearStroke": true}), + json!({"color": {"bad": true}, "stroke": false, "ids": [], "clearStroke": true}), + ] { + assert!(s.execute("paint.sampleColor", &p).is_err(), "{p}"); + assert_eq!((s.paint.clone(), node(&s, dst)), state, "{p}"); + } +} + #[test] fn every_appearance_attribute_copies_the_whole_stack() { let mut s = session(); diff --git a/crates/engine/src/tests/legacy_ai.rs b/crates/engine/src/tests/legacy_ai.rs index 52706abbd..7ac5c3c5c 100644 --- a/crates/engine/src/tests/legacy_ai.rs +++ b/crates/engine/src/tests/legacy_ai.rs @@ -520,6 +520,67 @@ fn pattern_over_blue(tile: &[u8], opacity: f32) -> Session { s } +/// A pattern of opaque grayscale pixels whose black squares draw red and white squares leave the blue background visible. +fn colorized_pattern_over_blue() -> Session { + fn colorize_image(n: &mut vectorcraft_doc::Node) -> bool { + if matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)) { + n.extras_mut().colorize = Some(Box::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(255, 0, 0)))); + return true; + } + n.children_mut().is_some_and(|children| children.iter_mut().any(|child| colorize_image(std::sync::Arc::make_mut(child)))) + } + + let tile = image::GrayImage::from_fn(32, 32, |x, y| image::Luma([if (x < 16) == (y < 16) { 0 } else { 255 }])); + let mut bytes = vec![]; + tile.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap(); + let mut s = pattern_over_blue(&bytes, 1.0); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + assert!(d.patterns[0].art.iter_mut().any(|art| colorize_image(std::sync::Arc::make_mut(art)))); + s +} + +#[test] +fn a_colorized_grayscale_image_pattern_fill_is_expanded() { + let mut s = colorized_pattern_over_blue(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let keys = crate::cmd::flatten::see_through_images(d); + assert!(keys.is_empty()); + let layer = d.layers[0].id; + assert_eq!(crate::cmd::expand::expand_transparent_image_patterns(d, layer, &keys).unwrap(), 1); + let mut patterns = 0; + let mut tile_images = 0; + d.walk(|n| { + patterns += n + .appearance + .items + .iter() + .filter( + |i| matches!(i, vectorcraft_doc::appearance::AppearanceItem::Fill(f) if matches!(f.paint, vectorcraft_color::Paint::Pattern { .. })), + ) + .count(); + tile_images += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))); + }); + assert_eq!(patterns, 0); + assert!(tile_images > 0); +} + +#[test] +fn colorized_grayscale_image_pattern_keeps_its_colors_in_legacy_ai() { + let mut s = colorized_pattern_over_blue(); + let points = [(72.0, 72.0), (88.0, 72.0), (136.0, 152.0), (152.0, 152.0)]; + let before: Vec<[u8; 3]> = points.iter().map(|&(x, y)| colour_at(&s.doc().unwrap().doc, x, y)).collect(); + assert_eq!(before[0], [255, 0, 0], "{before:?}"); + assert_eq!(before[1], [0, 0, 255], "{before:?}"); + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": "flatten"})).unwrap(); + let saved_warnings = warnings(&r); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + let after: Vec<[u8; 3]> = points.iter().map(|&(x, y)| colour_at(&s.doc().unwrap().doc, x, y)).collect(); + for (a, b) in before.iter().zip(&after) { + assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "before {before:?} after {after:?}"); + } + assert!(saved_warnings.iter().any(|w| w.contains("flattened with the art under them")), "{saved_warnings:?}"); +} + /// #3666: a pattern fill whose tile is an image, under opacity, is flattened with the art under it /// like a transparent image, instead of being written opaque over white. #[test] diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 32ea11330..df6f7db08 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -518,8 +518,9 @@ fn lock_and_hide_with_nothing_to_lock_or_hide_record_no_undo_step() { /// Hide › Unselected Artwork hides, in one undo step, each object that is not selected and holds no selected object: /// here `b`, the member `n` of the group that holds the selected `m`, and `p` on another layer. The selected objects, -/// that group, the clip group that holds the selected `c2` with all its members, a locked object, the art on a locked -/// layer and an object hidden before stay as they are, and so does the selection. Show All shows them again. +/// that group, the clip group that holds the selected `c2` with all its members, the compound path that holds the +/// selected `hole` with both paths, a guide under Lock Guides, a locked object, the art on a locked layer and an +/// object hidden before stay as they are, and so does the selection. Show All shows them again. #[test] fn hide_unselected_artwork_hides_every_object_that_is_not_selected() { let mut s = session(); @@ -534,6 +535,14 @@ fn hide_unselected_artwork_hides_every_object_that_is_not_selected() { let c3 = rect(&mut s, 80.0, 0.0, 30.0, 30.0); sel(&mut s, &[c1, c2, c3]); let clip = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()); + let outer = rect(&mut s, 0.0, 200.0, 100.0, 100.0); + let hole = rect(&mut s, 40.0, 240.0, 20.0, 20.0); + sel(&mut s, &[outer, hole]); + let compound = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()); + let guide = rect(&mut s, 200.0, 0.0, 10.0, 10.0); + sel(&mut s, &[guide]); + s.execute("view.guides.make", &json!({})).unwrap(); + s.execute("view.guides.lock", &json!({"locked": true})).unwrap(); let k = rect(&mut s, 120.0, 0.0, 10.0, 10.0); s.execute("layer.setProps", &json!({"id": k.0, "locked": true})).unwrap(); let h = rect(&mut s, 140.0, 0.0, 10.0, 10.0); @@ -544,13 +553,13 @@ fn hide_unselected_artwork_hides_every_object_that_is_not_selected() { let q = rect(&mut s, 20.0, 100.0, 10.0, 10.0); s.execute("layer.setProps", &json!({"id": locked_layer.0, "locked": true})).unwrap(); let shown = |s: &Session, ids: &[NodeId]| ids.iter().map(|id| node(s, *id).visible).collect::>(); - sel(&mut s, &[a, m, c2]); + sel(&mut s, &[a, m, c2, hole]); let steps = undo_len(&s); assert_eq!(s.execute("object.hide.unselected", &json!({})).unwrap(), json!({"count": 3})); assert_eq!(shown(&s, &[b, n, p, h]), [false; 4]); - assert_eq!(shown(&s, &[a, m, g, c1, c2, c3, clip, k, q]), [true; 9]); + assert_eq!(shown(&s, &[a, m, g, c1, c2, c3, clip, compound, outer, hole, guide, k, q]), [true; 13]); assert!(node(&s, k).locked); - assert_eq!((undo_len(&s), selected(&s)), (steps + 1, vec![a, m, c2])); + assert_eq!((undo_len(&s), selected(&s)), (steps + 1, vec![a, m, c2, hole])); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(shown(&s, &[b, n, p]), [true; 3]); s.execute("edit.redo", &json!({})).unwrap(); @@ -650,6 +659,151 @@ fn hide_unselected_artwork_keeps_groups_with_locked_guides_and_the_selected_laye assert_eq!(selected(&s), [other]); } +/// Lock Unselected Artwork locks `b`, `n` in the group that holds selected `m`, `p` on another layer, and the clip +/// group's unselected members `c1` and clipping path `c3` in one undo step labeled "Lock". The clip group container, +/// `c2`, the selected objects, group `g`, hidden object `h` and art `q` on a locked layer stay unlocked. `k` was locked +/// before. The selection stays as it is. Unlock All unlocks all six locked objects and selects them. +#[test] +fn lock_unselected_artwork_locks_every_object_that_is_not_selected() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + let m = rect(&mut s, 40.0, 0.0, 10.0, 10.0); + let n = rect(&mut s, 60.0, 0.0, 10.0, 10.0); + sel(&mut s, &[m, n]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + let c1 = rect(&mut s, 80.0, 0.0, 10.0, 10.0); + let c2 = rect(&mut s, 85.0, 5.0, 10.0, 10.0); + let c3 = rect(&mut s, 80.0, 0.0, 30.0, 30.0); + sel(&mut s, &[c1, c2, c3]); + let clip = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()); + let k = rect(&mut s, 120.0, 0.0, 10.0, 10.0); + s.execute("layer.setProps", &json!({"id": k.0, "locked": true})).unwrap(); + let h = rect(&mut s, 140.0, 0.0, 10.0, 10.0); + s.execute("layer.setProps", &json!({"id": h.0, "visible": false})).unwrap(); + s.execute("layer.new", &json!({})).unwrap(); + let p = rect(&mut s, 0.0, 100.0, 10.0, 10.0); + let locked_layer = id_of(&s.execute("layer.new", &json!({})).unwrap()); + let q = rect(&mut s, 20.0, 100.0, 10.0, 10.0); + s.execute("layer.setProps", &json!({"id": locked_layer.0, "locked": true})).unwrap(); + let locked = |s: &Session, ids: &[NodeId]| ids.iter().map(|id| node(s, *id).locked).collect::>(); + sel(&mut s, &[a, m, c2]); + let steps = undo_len(&s); + assert_eq!(s.execute("object.lock.unselected", &json!({})).unwrap(), json!({"count": 5})); + assert_eq!(locked(&s, &[b, n, p, c1, c3, k]), [true; 6]); + assert_eq!(locked(&s, &[a, m, g, c2, clip, h, q]), [false; 7]); + assert_eq!((undo_len(&s), selected(&s)), (steps + 1, vec![a, m, c2])); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Lock"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(locked(&s, &[b, n, p]), [false; 3]); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(locked(&s, &[b, n, p]), [true; 3]); + assert_eq!(s.execute("object.unlockAll", &json!({})).unwrap(), json!({"count": 6})); + assert_eq!(locked(&s, &[b, n, p, c1, c3, k]), [false; 6]); + let mut unlocked = selected(&s); + unlocked.sort(); + let mut want = vec![b, n, p, c1, c3, k]; + want.sort(); + assert_eq!(unlocked, want); +} + +/// Lock Unselected Artwork affects only the isolated group's other members. Lock Guides does not protect an unselected +/// guide from this command. With nothing left to lock it records no undo step. +#[test] +fn lock_unselected_artwork_scopes_isolation_and_locks_guides() { + let mut s = session(); + let guide = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[guide]); + s.execute("view.guides.make", &json!({})).unwrap(); + s.execute("view.guides.lock", &json!({"locked": true})).unwrap(); + let out = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + let m = rect(&mut s, 40.0, 0.0, 10.0, 10.0); + let n = rect(&mut s, 60.0, 0.0, 10.0, 10.0); + sel(&mut s, &[m, n]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + let lock = |s: &mut Session| s.execute("object.lock.unselected", &json!({})).unwrap(); + let locked = |s: &Session, ids: &[NodeId]| ids.iter().map(|id| node(s, *id).locked).collect::>(); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + select_with_a_step_to_redo(&mut s, &[m]); + let info = find_command("object.lock.unselected").unwrap().info(&s); + assert!(info.enabled, "{info:?}"); + let before = undo_len(&s); + assert_eq!(lock(&mut s), json!({"count": 1})); + assert_eq!(locked(&s, &[m, n, out, guide]), [false, true, false, false]); + assert_eq!((undo_len(&s), selected(&s)), (before + 1, vec![m])); + s.execute("object.exitIsolation", &json!({})).unwrap(); + sel(&mut s, &[m]); + assert!(find_command("object.lock.unselected").unwrap().info(&s).enabled); + assert_eq!(lock(&mut s), json!({"count": 2})); + assert_eq!(locked(&s, &[n, out, guide, g, m]), [true, true, true, false, false]); + select_with_a_step_to_redo(&mut s, &[m]); + let before = history(&s); + assert_eq!(lock(&mut s), json!({"count": 0})); + assert_eq!(history(&s), before); + s.execute("view.guides.lock", &json!({"locked": false})).unwrap(); +} + +/// Lock Unselected Artwork locks the unselected path of a partly selected compound path, but needs a selection. +#[test] +fn lock_unselected_artwork_descends_into_compound_and_needs_selection() { + let mut s = session(); + let outer = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let hole = rect(&mut s, 40.0, 40.0, 20.0, 20.0); + sel(&mut s, &[outer, hole]); + let compound = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()); + s.execute("select.none", &json!({})).unwrap(); + let before = history(&s); + assert!(!find_command("object.lock.unselected").unwrap().info(&s).enabled); + assert!(matches!(s.execute("object.lock.unselected", &json!({})), Err(EngineError::Disabled(..)))); + assert_eq!(history(&s), before); + sel(&mut s, &[hole]); + assert_eq!(s.execute("object.lock.unselected", &json!({})).unwrap(), json!({"count": 1})); + assert!(node(&s, outer).locked); + assert!(!node(&s, hole).locked); + assert!(!node(&s, compound).locked); + assert_eq!(selected(&s), [hole]); +} + +/// Lock Unselected Artwork leaves all art on a selected layer unlocked and locks unselected art on another layer. +#[test] +fn lock_unselected_artwork_keeps_a_selected_layers_art() { + let mut s = session(); + let selected_layer = id_of(&s.execute("layer.new", &json!({})).unwrap()); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + s.execute("layer.new", &json!({})).unwrap(); + let other = rect(&mut s, 40.0, 0.0, 10.0, 10.0); + sel(&mut s, &[selected_layer]); + assert_eq!(s.execute("object.lock.unselected", &json!({})).unwrap(), json!({"count": 1})); + assert_eq!([a, b, other].map(|id| node(&s, id).locked), [false, false, true]); + assert_eq!(selected(&s), [selected_layer]); +} + +/// In a plain group, Lock and Hide Unselected Artwork affect the same members of identical documents. +#[test] +fn lock_and_hide_unselected_artwork_choose_the_same_objects() { + let prepare = || { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + let m = rect(&mut s, 40.0, 0.0, 10.0, 10.0); + let n = rect(&mut s, 60.0, 0.0, 10.0, 10.0); + sel(&mut s, &[m, n]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + sel(&mut s, &[a, m]); + (s, [a, b, m, n, g]) + }; + let (mut locked, lock_ids) = prepare(); + let (mut hidden, hide_ids) = prepare(); + assert_eq!(lock_ids, hide_ids); + assert_eq!(locked.execute("object.lock.unselected", &json!({})).unwrap(), json!({"count": 2})); + assert_eq!(hidden.execute("object.hide.unselected", &json!({})).unwrap(), json!({"count": 2})); + let changed_by_lock: Vec<_> = lock_ids.into_iter().filter(|id| node(&locked, *id).locked).collect(); + let changed_by_hide: Vec<_> = hide_ids.into_iter().filter(|id| !node(&hidden, *id).visible).collect(); + assert_eq!(changed_by_lock, changed_by_hide); + assert_eq!(changed_by_lock, [lock_ids[1], lock_ids[3]]); +} + // ---------- Transform Each ---------- #[test] diff --git a/crates/engine/src/tests/pattern.rs b/crates/engine/src/tests/pattern.rs index 92131d02b..a23e621b9 100644 --- a/crates/engine/src/tests/pattern.rs +++ b/crates/engine/src/tests/pattern.rs @@ -368,6 +368,57 @@ fn transforms_without_transform_objects_move_only_the_pattern() { } } +#[test] +fn selection_backquote_drag_changes_pattern_fill_and_stroke_without_moving_mixed_art() { + let mut s = session(); + make_dots(&mut s); + let patterned = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + let solid = rect(&mut s, 220.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [patterned.0], "swatch": "Dots"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [patterned.0], "swatch": "Dots"})).unwrap(); + s.execute("prefs.set", &json!({"key": "transformPatternTiles", "value": false})).unwrap(); + sel(&mut s, &[patterned, solid]); + let before_patterned = node(&s, patterned); + let before_solid = node(&s, solid); + let history = crate::tests::history(&s).0; + let v = ViewInfo { show_bbox: false, ..Default::default() }; + s.select_tool("selection", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 125.0), v).unwrap(); + s.pointer(&PointerEvent { backquote: true, ..PointerEvent::new(PointerKind::Drag, 145.0, 125.0) }, v).unwrap(); + s.pointer(&PointerEvent { backquote: true, ..PointerEvent::new(PointerKind::Up, 145.0, 125.0) }, v).unwrap(); + let after = node(&s, patterned); + assert_eq!(after.geometric_bounds(), before_patterned.geometric_bounds()); + assert_eq!(node(&s, solid), before_solid); + for paint in [after.appearance.fill_paint(), after.appearance.stroke_paint()] { + let Paint::Pattern { xf, .. } = paint else { panic!("pattern paint expected") }; + assert_eq!(xf.as_coeffs()[4], 20.0); + } + assert_eq!(crate::tests::history(&s).0, history + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, patterned), before_patterned); + assert_eq!(node(&s, solid), before_solid); +} + +#[test] +fn selection_backquote_drag_changes_stroke_only_pattern() { + let mut s = session(); + make_dots(&mut s); + let id = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [id.0], "none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [id.0], "swatch": "Dots"})).unwrap(); + sel(&mut s, &[id]); + let before = node(&s, id).geometric_bounds(); + let v = ViewInfo { show_bbox: false, ..Default::default() }; + s.select_tool("selection", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 125.0), v).unwrap(); + s.pointer(&PointerEvent { backquote: true, ..PointerEvent::new(PointerKind::Drag, 120.0, 125.0) }, v).unwrap(); + s.pointer(&PointerEvent { backquote: true, ..PointerEvent::new(PointerKind::Up, 120.0, 125.0) }, v).unwrap(); + let after = node(&s, id); + assert_eq!(after.geometric_bounds(), before); + let Paint::Pattern { xf, .. } = after.appearance.stroke_paint() else { panic!("pattern stroke expected") }; + assert_eq!(xf.as_coeffs()[4], 20.0); +} + #[test] fn list_reports_patterns_and_edit_state() { let mut s = session(); diff --git a/crates/engine/src/tests/toolsettings.rs b/crates/engine/src/tests/toolsettings.rs index 3f7d1513b..f23c19c2f 100644 --- a/crates/engine/src/tests/toolsettings.rs +++ b/crates/engine/src/tests/toolsettings.rs @@ -112,10 +112,10 @@ fn alt_smooth_fidelity(s: &mut Session) -> Option { }) } -/// Alt switches Pencil and Blob Brush to Smooth, using Smooth Tool Options' stored Fidelity. +/// Alt switches Pencil, Paintbrush and Blob Brush to Smooth, using Smooth Tool Options' stored Fidelity. #[test] fn alt_smoothing_takes_the_smooth_tools_fidelity() { - for id in ["pencil", "blobBrush"] { + for id in ["pencil", "paintbrush", "blobBrush"] { let mut s = session(); s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); select(&mut s, "smooth"); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 997606253..8229979d0 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -220,8 +220,8 @@ impl Session { } } } - // Alt switches the Pencil or Blob Brush to Smooth, using Smooth Tool Options' Fidelity. - if matches!(t.id(), "pencil" | "blobBrush") + // Alt switches the Pencil, Paintbrush or Blob Brush to Smooth, using Smooth Tool Options' Fidelity. + if matches!(t.id(), "pencil" | "paintbrush" | "blobBrush") && let Some(v) = prefs.tool_settings.get("smooth").and_then(|s| s.get("fidelity")) { t.set_option("smoothFidelity", v); @@ -633,8 +633,8 @@ impl Session { /// Set options of tool `id` (default: the active tool). Another tool's persistent options are /// stored for when it is chosen, and the active tool takes those it shares with it (the - /// Liquify tools' Global Brush Dimensions, the Smooth tool's Fidelity for the Pencil's and - /// Blob Brush's Alt smoothing). Returns the tool's options. + /// Liquify tools' Global Brush Dimensions, the Smooth tool's Fidelity for Pencil, Paintbrush + /// and Blob Brush Alt smoothing). Returns the tool's options. pub fn set_tool_options(&mut self, id: Option<&str>, values: &Map) -> Value { let active = id.is_none_or(|id| id == self.tool.id()); self.tool_set_since_reset |= active; diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index c5f9fc9de..27c608831 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -419,6 +419,18 @@ fn structured_junk() { "addon.color.filter", json!({"effect": "addon.color.duotone", "params": {"tones": [{"color": "#ff0000", "swatch": "[Registration]"}], "skipSpot": true}}), ), + ("effect.apply", json!({"effect": "addon.path.dashes", "params": {"dash1": 1e308, "gap1": 1e308, "offset": -1e308, "alignCorners": true}})), + ("effect.apply", json!({"effect": "addon.path.dashes", "params": {"dash1": -1e308, "gap1": 1e-308, "dash5": 1e-9, "gap5": 0}})), + ("effect.apply", json!({"effect": "addon.path.addAnchors", "params": {"target": "x", "spacing": "x", "count": 1e308, "maxCount": 1e308}})), + ("effect.apply", json!({"effect": "addon.path.addAnchors", "params": {"spacing": "distance", "distance": 1e-308, "maxCount": -1e308}})), + ("effect.apply", json!({"effect": "addon.path.extend", "params": {"startLength": 1e308, "endLength": -1e308, "startPercent": -1e308}})), + ("effect.apply", json!({"effect": "addon.path.extend", "params": {"endPercent": 1e308, "endShape": "arc", "startShape": "x"}})), + ("effect.apply", json!({"effect": "addon.path.close", "params": {"mode": "x"}})), + ("effect.apply", json!({"effect": "addon.path.smooth", "params": {"ratio": 1e308}})), + ("effect.apply", json!({"effect": "addon.path.retract", "params": {"handles": "shorter", "length": 1e308}})), + ("effect.apply", json!({"effect": "addon.path.reverse"})), + ("effect.apply", json!({"effect": "addon.path.simplify", "params": {"tolerance": 1e308, "protectCorners": false}})), + ("effect.apply", json!({"effect": "addon.path.split", "params": {"anchors": "x"}})), ("addon.color.printFixes", json!({"inkLimit": 1e308, "overprintBlack": -1e308})), ("addon.color.printFixes", json!({"inkLimit": 1, "overprintBlack": 1, "removeWhiteOverprint": true, "scope": "document", "textOnly": true})), ("addon.color.printFixes", json!({"inkLimit": [300], "scope": 3, "ids": "x"})), @@ -543,6 +555,30 @@ fn structured_junk() { ("addon.pathtools.shape.set", json!({"id": 2, "center": [1, 2, 3], "radius": "big"})), ("addon.pathtools.shape.convert", json!({"ids": [0, 1, 2, 3, u64::MAX]})), ("addon.pathtools.shape.convert", json!({"ids": "all"})), + // Path Precision Tools: the Corner Tool and Slide Anchor Along Path. + ("addon.pathtools.cornerSet", json!({"ids": [2], "radius": 1e6, "kind": "chamfer"})), + ("addon.pathtools.cornerSet", json!({"ids": [2, 3], "anchors": [0, 1, u64::MAX], "radius": 5})), + ("addon.pathtools.cornerSet", json!({"items": [{"id": 2, "anchors": [0]}, {"id": u64::MAX}], "radius": 1e-300})), + ("addon.pathtools.cornerSet", json!({"items": [1, null, {"anchors": "x"}], "radius": 1})), + ("addon.pathtools.cornerSet", json!({"ids": [2], "radius": 1e308, "kind": "spiky"})), + ("addon.pathtools.cornerSet", json!({"items": "all", "radius": f64::NAN, "anchors": [-1, 1.5, "x"]})), + ("addon.pathtools.cornerRemove", json!({"ids": [0, 1, 2, 3, u64::MAX]})), + ("addon.pathtools.cornerRemove", json!({"items": [{"id": 2, "anchors": [u64::MAX]}]})), + ("addon.pathtools.slideAnchor", json!({"id": 2, "anchor": 1, "t": 0.98})), + ("addon.pathtools.slideAnchor", json!({"id": 2, "subpath": u64::MAX, "anchor": 0, "t": -0.5})), + ("addon.pathtools.slideAnchor", json!({"id": 2, "anchor": u64::MAX, "t": 1e308})), + ("addon.pathtools.slideAnchor", json!({"id": u64::MAX, "anchor": "x", "t": "y"})), + // Path Precision Tools: the Shared Edge Merge Tool and Keep Shared Points Together. + ("addon.pathtools.mergeEdge", json!({"a": 2, "b": 3, "at": [0, 0]})), + ("addon.pathtools.mergeEdge", json!({"a": 2, "b": 3, "at": [10, 10], "diameter": 200, "tolerance": 3, "ends": true})), + ("addon.pathtools.mergeEdge", json!({"a": 2, "b": 2, "at": [0, 0]})), + ("addon.pathtools.mergeEdge", json!({"a": 2, "b": u64::MAX, "at": [1e308, -1e308]})), + ("addon.pathtools.mergeEdge", json!({"a": 2, "b": 3, "at": [1, 2, 3], "diameter": 1e308, "tolerance": -1, "follow": "up"})), + ("addon.pathtools.mergeEdge", json!({"a": "x", "b": null, "at": "y", "follow": 3, "ends": "z"})), + ("addon.pathtools.movePointsTogether", json!({"enabled": "maybe"})), + ("addon.pathtools.movePointsTogether", json!({"enabled": true})), + ("addon.pathtools.slideAnchor", json!({"id": 2, "anchor": 1, "t": 0.5})), + ("addon.pathtools.movePointsTogether", json!({"enabled": false})), // Smart Pen add-on. ("addon.pen.append", json!({"id": 2, "x": 1e308, "y": 0, "in": [1e308, -1e308]})), ("addon.pen.append", json!({"id": 2, "subpath": u64::MAX, "x": 0, "y": 0, "segment": "curved"})), @@ -559,6 +595,23 @@ fn structured_junk() { ("addon.pen.reshape", json!({"id": 2, "segment": 0, "t": "x", "dx": 1, "dy": 1, "mode": "symmetric"})), ("addon.pen.nextSegment", json!({"curved": "yes"})), ("addon.pen.closeJoin", json!({"ids": [u64::MAX]})), + ("addon.cleanup.breakText", json!({"by": "glyphs", "ids": [0, 1, 2, 3, 4, 5, 6, 7, 8, u64::MAX]})), + ("addon.cleanup.breakText", json!({"by": ["lines"]})), + ("addon.cleanup.breakText", json!({"by": "paragraphs"})), + ("addon.cleanup.breakText", json!({"by": "words", "ids": "all"})), + ("addon.cleanup.combineText", json!({"ids": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]})), + ("addon.cleanup.combineText", json!({"ids": [-1, 1e308]})), + ("addon.cleanup.removeBreaks", json!({"kind": "paragraph", "ids": [1, 2, 3, 4, 5, 6, 7, 8, u64::MAX]})), + ("addon.cleanup.removeBreaks", json!({"kind": 3})), + ("addon.cleanup.removeBreaks", json!({"kind": "line", "ids": null})), + ("addon.cleanup.splitText", json!({"at": 1e308})), + ("addon.cleanup.alignInPlace", json!({"align": "right", "ids": [1, 2, 3, 4, 5, 6, 7, 8]})), + ("addon.cleanup.alignInPlace", json!({"align": null, "ids": [-1]})), + ("addon.cleanup.resetTextTransform", json!({"scale": "yes"})), + ("addon.cleanup.resetTextTransform", json!({"scale": true, "ids": [1, 2, 3, 4, 5, 6, 7, 8]})), + ("addon.cleanup.replaceFonts", json!({"font": ""})), + ("addon.cleanup.replaceFonts", json!({"font": ["Source Sans 3"]})), + ("addon.cleanup.replaceFonts", json!({"font": "\u{0}"})), ]; let mut failures = vec![]; for fx in Fixture::ALL { @@ -571,6 +624,43 @@ fn structured_junk() { assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); } +/// Artwork Cleanup's text tools while a Type tool edits type with junk ranges (past the end, +/// reversed, inside a character): never a panic, and the document stays sound. +#[test] +fn cleanup_text_tools_with_junk_editing_ranges() { + let ranges = [(0, u64::MAX / 4), (u64::MAX, 1), (5, 5), (1, 2), (u64::MAX / 4, u64::MAX / 4)]; + let mut failures = vec![]; + for fx in Fixture::ALL { + let mut texts = vec![]; + for l in &fx.session().doc().unwrap().doc.layers { + l.walk(&mut |n| { + if matches!(n.kind, vectorcraft_doc::NodeKind::Text(_)) { + texts.push(n.id.0); + } + }); + } + for id in texts { + for (anchor, caret) in ranges { + for (cmd, p) in [("addon.cleanup.splitText", json!({})), ("addon.cleanup.removeBreaks", json!({"kind": "paragraph"}))] { + let mut s = fx.session(); + let r = catch_quiet(|| { + let _ = s.execute("select.set", &json!({"ids": [id]})); + let _ = s.select_tool("type", Default::default()); + s.set_tool_option("textSelection", &json!({"editing": id, "anchor": anchor, "caret": caret})); + no_panic(s.execute(cmd, &p)) + }); + if let Err(msg) = r { + failures.push(format!("PANIC {cmd} {id} {anchor}..{caret} [{fx:?}]: {msg}")); + } else if let Err(e) = check_session(&s) { + failures.push(format!("{cmd} {id} {anchor}..{caret} [{fx:?}]: {e}")); + } + } + } + } + } + assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); +} + /// Shape Builder Tool Options (Gap Detection) with junk values: never a panic, and the tool still /// answers with options and works on a drag afterwards. #[test] diff --git a/crates/eps/src/import/ai/mesh.rs b/crates/eps/src/import/ai/mesh.rs index b33869a95..3937045db 100644 --- a/crates/eps/src/import/ai/mesh.rs +++ b/crates/eps/src/import/ai/mesh.rs @@ -245,8 +245,9 @@ impl MeshReader { _ => return None, }; let &[x, y, nx, ny, nw, px, py, pw] = geometry else { return None }; - // Non-unit rational weights need a different surface representation. - if nw != 1.0 || pw != 1.0 { + // Weights are 1, or 0 on some corners, which draw as the same cubic edges (checked + // against the files' pages). Others would need a rational surface. + if ![nw, pw].iter().all(|w| *w == 1.0 || *w == 0.0) { return None; } if ![opacity, x, y, nx, ny, px, py].iter().all(|v| v.is_finite()) { @@ -631,6 +632,25 @@ mod tests { assert!((first.handles[H_DOWN] - Vec2::new(0.0, 1.0)).hypot() < 1e-9, "{:?}", first.handles); } + /// A corner's handle weights are 1, or 0 on some corners, which draw as the same patch; any + /// other weight leaves the patch unread. + #[test] + fn corners_with_handle_weights_of_zero_are_read() { + let read = |nw: f64, pw: f64| { + let mut reader = MeshReader::default(); + patch(&mut reader, &[0.5, 1.0, 0.0, 0.0, 0.0, 0.3, nw, 0.3, 0.0, pw]); + reader + }; + for (nw, pw) in [(0.0, 0.0), (0.0, 1.0), (1.0, 0.0)] { + let reader = read(nw, pw); + assert!(!reader.unread, "{nw} {pw}"); + let p = &reader.patches[0].points[0]; + assert_eq!((p.handles[H_DOWN], p.handles[H_RIGHT]), (Vec2::new(0.0, 0.3), Vec2::new(0.3, 0.0))); + } + let reader = read(0.5, 1.0); + assert!(reader.unread && reader.patches.is_empty()); + } + #[test] fn hostile_meshes_are_capped_and_checked() { let mut reader = MeshReader::default(); diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index cc060f3f1..5cf477d75 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -183,8 +183,14 @@ struct Reader<'a> { blend_budget: usize, /// A group just ended, its state (`Xy`, `Xd`, `XW`) not yet read. group_ended: bool, + /// A gradient mesh just ended (`/End X!`), its own state (`Xy`) not yet read: also after its + /// dictionary (its name, its opacity mask and the mask's art). + mesh_ended: bool, /// A layer's own state (`Xy` after its art, before `LB`), until something says the art moves on. layer_state: Option<(f32, BlendMode, bool, Knockout)>, + /// The state a mesh or blend had before an `Xy` right after its end, which is the layer's + /// instead when the layer's `LB` follows rather than their own `6 () XW`. + mesh_undo: Option<(f32, BlendMode, bool, Knockout)>, /// Painted copies of clipping paths that have a fill (drawn under what they clip). clip_fills: HashSet, /// Groups closed as an object's drawn look (`9 () XW`). @@ -288,7 +294,9 @@ impl<'a> Reader<'a> { compound_groups: HashSet::new(), text_looks: HashSet::new(), group_ended: false, + mesh_ended: false, layer_state: None, + mesh_undo: None, styles: HashMap::new(), palette: super::palette::Palette::default(), restyled: vec![], diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 6bf699ce9..89df83cd4 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -235,7 +235,9 @@ impl<'a> Reader<'a> { let mut node = self.new_node(NodeKind::Mesh(one), &gs); node.opacity = gs.opacity; node.blend = gs.blend; - return self.add(node, hidden); + self.add(node, hidden)?; + self.mesh_ended = true; + return Ok(()); } Err(crate::import::ai::mesh::NotOne::Unjoined) => { self.warn("a gradient mesh whose patches don't join up comes in as a group of its patches") @@ -249,7 +251,9 @@ impl<'a> Reader<'a> { }; node.opacity = gs.opacity; node.blend = gs.blend; - self.add(node, hidden) + self.add(node, hidden)?; + self.mesh_ended = true; + Ok(()) } /// Stroke the path just painted (a gradient-filled one, `1 BB` or `2 BB`) with the stroke's state. diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 09ccd14aa..8d7093398 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -19,6 +19,7 @@ impl<'a> Reader<'a> { children: Vec::new(), hidden, after: self.after_object, + mesh_ended: self.mesh_ended, gs: self.gs.clone(), base, clips: Vec::new(), @@ -171,7 +172,14 @@ impl<'a> Reader<'a> { } pub(super) fn end_layer(&mut self) -> Result<(), String> { - let f = self.close("LB", |k| matches!(k, Kind::Layer(_)))?; + let mut f = self.close("LB", |k| matches!(k, Kind::Layer(_)))?; + let state = self.layer_state.take(); + // An `Xy` right after the layer's last mesh or blend, then `LB`: the layer's, not theirs. + if let (Some(_), Some(before)) = (state, self.mesh_undo.take()) + && let Some(mesh) = f.children.last_mut().filter(|n| matches!(n.kind, NodeKind::Mesh(_) | NodeKind::Blend { .. })) + { + (mesh.opacity, mesh.blend, mesh.isolate, mesh.knockout) = before; + } let Kind::Layer(a) = f.kind else { return Ok(()) }; let (children, clip) = self.clip_first(f.children, &f.clips); let color = a.color.unwrap_or(LayerColor::Preset(0)); @@ -183,7 +191,7 @@ impl<'a> Reader<'a> { layer.visible = a.visible; layer.locked = a.locked; // The layer's own opacity, blend and knockout, written after its art (#2003). - if let Some((opacity, blend, isolate, knockout)) = self.layer_state.take() { + if let Some((opacity, blend, isolate, knockout)) = state { (layer.opacity, layer.blend, layer.isolate, layer.knockout) = (opacity, blend, isolate, knockout); } match self.frames.last_mut() { @@ -236,6 +244,7 @@ impl<'a> Reader<'a> { let art = self.new_node(NodeKind::Group { children, clip }, &f.gs); self.gs = f.gs; self.after_object = f.after; + self.mesh_ended = f.mesh_ended; let Some(Frame { kind: Kind::Obj(o), .. }) = self.frames.last_mut() else { return Err("mask art ends outside a mask dictionary".into()); }; @@ -251,13 +260,15 @@ impl<'a> Reader<'a> { return Ok(()); } let Some(f) = self.frames.pop() else { return Ok(()) }; - let Frame { kind, children, base, hidden, gs, after, .. } = f; + let Frame { kind, children, base, hidden, gs, after, mesh_ended, .. } = f; let Kind::Obj(mut o) = kind else { return Ok(()) }; obj::close(&mut o, &mut self.stack, base); self.obj_hidden = hidden; - // Art made inside a dictionary (type's paths) isn't kept. + // Art made inside a dictionary (type's paths, a mask's art) isn't kept, and doesn't end what + // follows the object the dictionary is about: a mesh's state comes after its mask. self.gs = gs; self.after_object = after; + self.mesh_ended = mesh_ended; if matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) { return self.push(V::Obj(Rc::new(*o))); } @@ -392,6 +403,8 @@ impl<'a> Reader<'a> { self.warn(crate::import::ai::blend::DRAWN); } } + // The blend's own state block follows its record, as a group's follows its end. + self.after_object = true; } } diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 9431061fe..65cec1b07 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -72,13 +72,9 @@ impl<'a> Reader<'a> { "X+" => return Ok(()), _ => {} } - // What follows an object (its name, transparency and style) ends with anything else. The - // state block of a layer whose art ended (`Xy`, `Xd`, `XW`, then `LB`) is read at the `LB`. - if !matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB") { - self.after_object = false; - self.layer_state = None; - } + self.state_block_continues(w); let group_ended = std::mem::take(&mut self.group_ended); + let mesh_ended = std::mem::take(&mut self.mesh_ended); let vals = self.operands(); let vals = self.take_head(vals); // Only `Bh` reads an undefined number. Anywhere else it would reach a point or a size, so @@ -201,7 +197,7 @@ impl<'a> Reader<'a> { } } // Transparency and state. - "Xy" => self.transparency(&vals), + "Xy" => self.transparency(&vals, mesh_ended), // A group's own state (`Xd`, `6 () XW`) without an `Xy` keeps the current transparency, as // the file's PDF part draws it. A look's group (`9 () XW`) has no state of its own. "Xd" if group_ended => { @@ -212,7 +208,13 @@ impl<'a> Reader<'a> { } "Xw" => self.gs.hidden = Self::nums(&vals).last() == Some(&1.0), "A" => self.gs.locked = Self::nums(&vals).last() == Some(&1.0), - "XW" => self.style_marker(&vals, hidden), + "XW" => { + // An object's own state block ends `6 () XW`: an `Xy` after a mesh's end was the mesh's. + if Self::nums(&vals).last() == Some(&6.0) && self.mesh_undo.take().is_some() { + self.layer_state = None; + } + self.style_marker(&vals, hidden) + } // Gradients. "Bd" => self.gradient_def = GradientDef::begin(&vals), "Bs" | "BD" => {} @@ -388,8 +390,26 @@ impl<'a> Reader<'a> { }); } - /// `mode opacity isolate knockout shape Xy`; after a group's end, the group's too. - fn transparency(&mut self, vals: &[V]) { + /// Operator `w` ends what follows an object (its name, transparency and style) unless it is part + /// of a state block. + /// The state block of a layer whose art ended (`Xy`, `Xd`, `XW`, then `LB`) is read at the + /// `LB`, and can have the stroke style between its `Xy` and `Xd`: that keeps the layer's state, + /// and what a mesh or blend before it had (`mesh_undo`). + fn state_block_continues(&mut self, w: &str) { + if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB") { + return; + } + self.after_object = false; + if !matches!(w, "J" | "j" | "w" | "M" | "d" | "XR") { + self.layer_state = None; + self.mesh_undo = None; + } + } + + /// `mode opacity isolate knockout shape Xy`; after a group's end, the group's too, and right + /// after a gradient mesh's `/End X!` or a blend's record (before their `Xd` and `6 () XW`), + /// theirs, unless the layer's `LB` follows: then it is the layer's (see `end_layer`). + fn transparency(&mut self, vals: &[V], mesh_ended: bool) { let n = Self::nums(vals); let [mode, opacity, rest @ ..] = n.as_slice() else { return }; let gs = &mut self.gs; @@ -403,7 +423,16 @@ impl<'a> Reader<'a> { }; gs.knockout_shape = rest.get(2).is_some_and(|v| *v != 0.0); let gs = self.gs.clone(); - if self.last_object().is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. })) { + let in_layer = matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. })); + if let Some(n) = + self.last_object().filter(|n| (mesh_ended && matches!(n.kind, NodeKind::Mesh(_))) || matches!(n.kind, NodeKind::Blend { .. })) + { + // The mesh's or blend's, or the layer's when it is the layer's last object: kept for either. + let before = (n.opacity, n.blend, n.isolate, n.knockout); + (n.opacity, n.blend, n.isolate, n.knockout) = (gs.opacity, gs.blend, gs.isolate, gs.knockout); + self.mesh_undo = Some(before); + self.layer_state = in_layer.then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); + } else if self.last_object().is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. })) { if let Some(n) = self.last_object() { n.opacity = gs.opacity; n.blend = gs.blend; @@ -415,8 +444,7 @@ impl<'a> Reader<'a> { // After a layer's art and before its `LB`: the layer's own state (#2003). Kept until // an operator that isn't part of the state block (or the `LB` that takes it) says the // art moves on, so a path's state before its paint isn't read as its layer's. - self.layer_state = - matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. })).then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); + self.layer_state = in_layer.then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); } } @@ -427,6 +455,10 @@ impl<'a> Reader<'a> { let code = Self::nums(vals).last().copied().unwrap_or(0.0); let style = vals.iter().rev().find_map(V::text).unwrap_or_default(); let after = std::mem::take(&mut self.after_object); + // An object's own state block ends `6 () XW` (a layer's `7 () XW`): an `Xy` before it isn't the layer's. + if code == 6.0 { + self.layer_state = None; + } if code == 9.0 && let Some(look) = self .frames diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 95423c94e..481a5670a 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -142,6 +142,8 @@ pub(super) struct Frame { pub(super) hidden: bool, /// [`Reader::after_object`] when it opened. pub(super) after: bool, + /// [`Reader::mesh_ended`] when it opened. + pub(super) mesh_ended: bool, /// The state when it opened (a container's lock and visibility; a dictionary's state to go /// back to). pub(super) gs: GState, @@ -246,6 +248,9 @@ pub(super) const SKIPPED: &[(&str, &str)] = &[ ("BeginSymbolList", "EndSymbolList"), ("BeginTextDocument", "EndTextDocument"), ("BeginEncoding", "EndEncoding"), + // The printer's crop marks, drawn after the layers (`userdict /AI3_noCropMarks known not { + // … } if`); the crop area they mark is `%AI3_Cropmarks`. + ("BeginCrops", "EndCrops"), ]; /// The note for versioned content whose `%AI17_End_Versioned_Content` is missing: every diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 9b453de24..649d51c38 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -391,6 +391,53 @@ fn a_layer_keeps_its_own_transparency() { let doc = read_ok(&layer(&art_)).doc; assert_eq!(doc.layers[0].opacity, 1.0, "no layer state written: the layer stays opaque"); assert_eq!(art(&doc)[0].opacity, 0.5, "the group's own state"); + // A layer whose last object is a gradient mesh: an `Xy` right after the mesh's end is the + // layer's when `LB` follows (directly or after the layer's `Xd` and `7 () XW`), and the mesh's + // when its own `6 () XW` follows. + let corner = + |x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| format!("%_[1 1 1 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1); + let mesh = format!( + "0 0.8 0 0 0 Xy\n/Mesh X!\n%_(DeviceRGB) /CS X#\n%_[0 0] /P X#\n{}{}{}{}%_0 /E X#\n/End X!\n", + corner(0, 0, (0, 10), (10, 0)), + corner(0, 40, (10, 40), (0, 30)), + corner(40, 40, (40, 30), (30, 40)), + corner(40, 0, (30, 0), (40, 10)), + ); + for (after, layer_state, mesh_state) in [ + ("1 0.35 0 0 0 Xy\n", (0.35, BlendMode::Multiply), (0.8, BlendMode::Normal)), + ("1 0.35 0 0 0 Xy\n0 0 Xd\n7 () XW\n", (0.35, BlendMode::Multiply), (0.8, BlendMode::Normal)), + ("1 0.35 0 0 0 Xy\n0 0 Xd\n6 () XW\n", (1.0, BlendMode::Normal), (0.35, BlendMode::Multiply)), + // The stroke style in the layer's state block. + ("1 0.35 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 0 Xd\n7 () XW\n", (0.35, BlendMode::Multiply), (0.8, BlendMode::Normal)), + ] { + let doc = read_ok(&layer(&format!("{mesh}{after}"))).doc; + let l = &doc.layers[0]; + assert!(matches!(art(&doc)[0].kind, NodeKind::Mesh(_)), "{after:?}"); + assert_eq!((l.opacity, l.blend), layer_state, "the layer's state after {after:?}"); + assert_eq!((art(&doc)[0].opacity, art(&doc)[0].blend), mesh_state, "the mesh's state after {after:?}"); + } +} + +#[test] +fn a_layers_state_keeps_through_the_stroke_style_written_in_it() { + // GUARDS: a layer's state block can have the stroke style between its `Xy` and `Xd`; read as + // the art moving on, it lost the layer's opacity and the layer drew opaque. + let state = "0 0.25 0 2 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 0 Xd\n7 () XW\n"; + let clip_group = format!( + "q\n{}h\nW\nn\n0 Ae\nu\n{RED}{}f\nU\n9 () XW\nQ\n9 () XW\n{state}", + rect_path(10.0, 10.0, 90.0, 90.0), + rect_path(0.0, 0.0, 100.0, 100.0) + ); + let path = format!("{RED}{}f\n{state}", rect_path(0.0, 0.0, 10.0, 10.0)); + for body in [clip_group, path] { + let doc = read_ok(&layer(&body)).doc; + let l = &doc.layers[0]; + assert_eq!((l.opacity, l.knockout), (0.25, Knockout::Neutral), "{body}"); + assert_eq!(art(&doc)[0].opacity, 1.0, "{body}"); + } + // An object's own state block (`6 () XW`) isn't the layer's. + let note = format!("{RED}{}f\n{}", rect_path(0.0, 0.0, 10.0, 10.0), state.replace("7 () XW", "6 () XW")); + assert_eq!(read_ok(&layer(¬e)).doc.layers[0].opacity, 1.0); } #[test] @@ -410,6 +457,57 @@ fn transparency_of_objects_and_groups() { assert_eq!((a[2].opacity, a[2].blend), (1.0, BlendMode::Normal), "the next object's state isn't the group's"); } +#[test] +fn a_gradient_mesh_takes_the_transparency_written_after_it() { + // GUARDS: a mesh's own state (`Xy`) comes right after its `/End X!`, before `Xd` and `XW`, as a + // group's does after `U`; read as the next object's, a Multiply shadow mesh painted opaque. + let corner = + |x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| format!("%_[1 1 1 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1); + let mesh = |x: i32| { + format!( + "/Mesh X!\n%_(DeviceRGB) /CS X#\n%_[0 0] /P X#\n{}{}{}{}%_0 /E X#\n/End X!\n", + corner(x, 0, (x, 10), (x + 10, 0)), + corner(x, 40, (x + 10, 40), (x, 30)), + corner(x + 40, 40, (x + 40, 30), (x + 30, 40)), + corner(x + 40, 0, (x + 30, 0), (x + 40, 10)), + ) + }; + let art_ = format!( + "0 1 0 0 0 Xy\n{}1 0.8 0 0 0 Xy\n0 0 Xd\n6 () XW\n{}0 0 Xd\n6 () XW\n2 0.7 0 0 0 Xy\n{RED}{}f\n", + mesh(0), + mesh(50), + rect_path(100.0, 0.0, 110.0, 10.0) + ); + let doc = read_ok(&layer(&art_)).doc; + let a = art(&doc); + assert!(matches!(a[0].kind, NodeKind::Mesh(_)) && matches!(a[1].kind, NodeKind::Mesh(_)), "{a:#?}"); + assert_eq!((a[0].opacity, a[0].blend), (0.8, BlendMode::Multiply), "the state after the mesh is its own"); + assert_eq!((a[1].opacity, a[1].blend), (0.8, BlendMode::Multiply), "the next mesh keeps the current state"); + assert_eq!((a[2].opacity, a[2].blend), (0.7, BlendMode::Screen), "a state after the mesh's `Xd` and `XW` is the next object's"); +} + +#[test] +fn a_masked_gradient_mesh_takes_the_transparency_written_after_its_mask() { + // GUARDS: a mesh with an opacity mask has its state after the mask's dictionary, whose art (on + // `%_` lines) has operators of its own; read as the next object's, each masked mesh in a run + // drew with the blend mode of the one before it. + let corners: String = ["10 20 10 25 1 15 20 1", "10 50 15 50 1 10 45 1", "40 50 40 45 1 35 50 1", "40 20 35 20 1 40 25 1"] + .iter() + .map(|g| format!("%_[0.5 1 {g}] /N X#\n")) + .collect(); + let mesh = format!("/Mesh X!\n%_/DeviceGray /CS X#\n{corners}%_/E X#\n/End X!\n"); + let mask = "%_/ArtDictionary :\n%_/Mask :\n%_X=\n%_u\n%_1 g\n%_10 20 m\n%_10 50 L\n%_40 50 L\n%_40 20 L\n%_f\n%_U\n%_X+\n\ + %_(Art) ,\n%_1 /Bool (Clipping) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n"; + let next = format!("0 1 0 0 0 Xy\n{RED}{}f\n", rect_path(60.0, 20.0, 80.0, 40.0)); + for (state, expected) in [("1 0.5 0 0 0 Xy\n", (0.5, BlendMode::Multiply)), ("", (1.0, BlendMode::Overlay))] { + let a = art(&read_ok(&layer(&format!("3 1 0 0 0 Xy\n{mesh}{mask}{state}0 0 Xd\n6 () XW\n{next}"))).doc); + assert_eq!(a.len(), 2, "{a:?}"); + assert!(matches!(a[0].kind, NodeKind::Mesh(_)) && a[0].extras().mask.is_some(), "{:?}", a[0]); + assert_eq!((a[0].opacity, a[0].blend), expected, "{state:?}: the state after the mask is the mesh's"); + assert_eq!((a[1].opacity, a[1].blend), (1.0, BlendMode::Normal), "the next object's own state"); + } +} + #[test] fn gradients() { let defs = "%AI5_BeginGradient: (G)\n(G) 1 2 Bd\n[\n<00FF>\n0\n0 0 0 0 1 0 0 2 1 6 %_BS\n\ @@ -1871,6 +1969,33 @@ fn a_blend_it_cant_read_keeps_its_drawn_steps() { } } +/// A blend's transparency (here knockout on, as blends are written) is the state block after its +/// record, as a group's follows its end: the live blend's, or its drawn steps' where they stay. It +/// is the blend's own, not the next object's. +#[test] +fn a_blends_transparency_follows_its_record() { + let state = "0 0.6 0 1 0 Xy\n0 0 Xd\n6 () XW\n"; + let next = format!("0 1 0 0 0 Xy\n{RED}{}f\n", rect_path(150.0, 40.0, 170.0, 60.0)); + let straight = "%_10 50 m\n%_110 50 L\n"; + let garbled = "(Adobe Path Blends) 1 0 16 XP\n%00112233445566778899AABBCCDDEEFF\n".to_string(); + for (record, live) in [(blend_record(&['A', 'B']), true), (garbled, false)] { + let body = format!("{}{state}{next}", blend_file(straight, [Some("A"), Some("B")], &record)); + let a = art(&read_ok(&layer(&body)).doc); + assert_eq!(a.len(), 2, "{a:?}"); + assert_eq!(matches!(a[0].kind, NodeKind::Blend { .. }), live, "{:?}", a[0].kind); + assert_eq!((a[0].opacity, a[0].knockout), (0.6, Knockout::On), "live: {live}"); + assert_eq!(a[1].opacity, 1.0, "the next object's own state"); + } + // A layer whose last object is a blend: a state block that ends `7 () XW` before `LB` is the + // layer's, and the blend keeps the state it had. + let body = format!("{}1 0.35 0 0 0 Xy\n0 0 Xd\n7 () XW\n", blend_file(straight, [Some("A"), Some("B")], &blend_record(&['A', 'B']))); + let doc = read_ok(&layer(&body)).doc; + assert_eq!((doc.layers[0].opacity, doc.layers[0].blend), (0.35, BlendMode::Multiply), "the layer's state"); + let a = art(&doc); + assert!(matches!(a[0].kind, NodeKind::Blend { .. }), "{:?}", a[0].kind); + assert_eq!((a[0].opacity, a[0].blend), (1.0, BlendMode::Normal), "the blend's own state"); +} + /// The blend is live: moving a key moves the steps between it and the other key with it. #[test] fn moving_a_blends_key_moves_its_steps() { @@ -1900,3 +2025,15 @@ fn a_blend_record_is_read_to_its_length() { let (blend, warnings) = read_blend(&blend_file("%_10 50 m\n%_110 50 L\n", [Some("A"), Some("B")], &longer)); assert!(matches!(blend.kind, NodeKind::Blend { .. }), "{warnings:?}"); } + +/// The printer's crop marks Illustrator draws after the last layer (`%AI3_BeginCrops`, inside +/// `userdict /AI3_noCropMarks known not { … } if`) aren't art outside the layers: the file reads +/// from its layers, without them (the crop area is `%AI3_Cropmarks`). +#[test] +fn crop_marks_after_the_layers_are_not_art() { + let crops = "%AI3_BeginCrops\nuserdict /AI3_noCropMarks known not {\n0 A\nu\n0 R\n1 1 1 1 ([Registration]) 0 XS\n\ + 0 J 0 j 0.5 w 4 M []0 d\n0 XR\n-9 -400 m\n-36 -400 L\nS\nU\n} if\n%AI3_EndCrops\n"; + let r = read_ok(&format!("{}{crops}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))); + assert_eq!(art(&r.doc).len(), 1); + assert_eq!(r.doc.layers.len(), 1); +} diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 21198305d..875a046fc 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -281,6 +281,8 @@ pub(super) struct Story { paras: Vec<(usize, Para)>, /// `(characters, style)` in order. runs: Vec<(usize, Style)>, + /// Type on a path: the spacing its first frame's layout implies ([`path_spacing`]). + path_spacing: f64, } #[derive(Clone, Debug)] @@ -408,8 +410,11 @@ impl Texts { return None; } // The first frame's layout, when the file keeps one: where its lines start. - let (mut anchor, mut lines) = ((0.0, 0.0), vec![]); + let (mut anchor, mut lines, mut path_spacing) = ((0.0, 0.0), vec![], 0.0); if let Some(f) = view.get("2").and_then(|l| find_node(l, "F")) { + if matches!(frames.first(), Some(FrameKind::OnPath { .. })) { + path_spacing = self::path_spacing(f); + } if let Some(a) = f.get("0").and_then(|a| a.get("0")).and_then(Val::nums) && let [x, y, ..] = a.as_slice() { @@ -441,7 +446,7 @@ impl Texts { if runs.is_empty() { runs.push((text.chars().count(), self.style(None))); } - Some(Story { text, anchor, lines, frames, paras, runs }) + Some(Story { text, anchor, lines, frames, paras, runs, path_spacing }) } /// A paragraph's attributes: its own keys, else the document's. @@ -572,6 +577,50 @@ fn length_fraction(segments: &[[f64; 8]], t: f64) -> f64 { ((before + within) / total).clamp(0.0, 1.0) } +/// The spacing (Type on a Path Options › Spacing) that the layout `f` of type on a path implies. +/// The file closes its glyphs up round the outside of a curve and opens them up round the inside, +/// even where its frame keeps no spacing; the text engine spaces glyphs that way by the spacing +/// (`vectorcraft_text`'s `path_steps`): between two glyphs' centres, the path's length `s`, their +/// advances' mean `a` and how far it turns `t` (away from their tops) make `s + t × spacing = a`. +/// Fitted over every pair of the layout's glyphs; none where the path doesn't turn under them. +fn path_spacing(f: &Val) -> f64 { + let mut found = vec![]; + find_nodes(f, "G", &mut found); + let (mut sum, mut turns) = (0.0, 0.0); + for g in found { + // Each glyph's origin, its matrix (along its baseline, then towards its foot) and where it + // ends along the line; the lists end with where the line ends. + let Some(at) = g.get("21") else { continue }; + let list = |k: &str| at.get(k).and_then(Val::nums).unwrap_or_default(); + let (xs, ys, matrices, ends) = (list("1"), list("2"), list("3"), list("4")); + let glyphs: Vec<(Point, f64, kurbo::Vec2, kurbo::Vec2)> = xs + .iter() + .zip(&ys) + .zip(matrices.as_chunks::<6>().0) + .zip(ends.windows(2)) + .map(|(((x, y), [a, b, c, d, ..]), e)| { + let advance = e.get(1).zip(e.first()).map_or(0.0, |(e1, e0)| e1 - e0); + let along = kurbo::Vec2::new(*a, *b); + (Point::new(*x, *y) + along * (advance * 0.5), advance, along, kurbo::Vec2::new(*c, *d)) + }) + .collect(); + for w in glyphs.windows(2) { + let [(p, a0, along, foot), (q, a1, next, _)] = w else { continue }; + let turn = next.dot(*foot).atan2(next.dot(*along)); + // The length of the arc between the centres (the path is taken to be round there). A + // corner (a turn of more than a radian between two glyphs) isn't round: left out. + let half = turn.abs() * 0.5; + let length = if half > 1e-9 { (*q - *p).hypot() * half / half.sin() } else { (*q - *p).hypot() }; + if turn.abs() <= 1.0 && length.is_finite() && a0.is_finite() && a1.is_finite() { + sum += ((a0 + a1) * 0.5 - length) * turn; + turns += turn * turn; + } + } + } + let spacing = sum / turns; + if turns > 1e-6 && spacing.is_finite() { spacing.clamp(-1000.0, 1000.0) } else { 0.0 } +} + impl Story { /// How many bytes of text the story has. pub(super) fn len(&self) -> usize { @@ -711,6 +760,9 @@ impl Story { if paras.len() > 1 && paras.iter().any(|p| *p != t.para) { t.paras = paras; } + if matches!(kind, TextKind::OnPath { .. }) && !threaded { + t.path_spacing = self.path_spacing * scale; + } t.kind = kind; t.xf = xf; Some(Node::new(id, NodeKind::Text(Box::new(t)))) @@ -1015,9 +1067,61 @@ mod tests { caps: 0, }, )], + path_spacing: 0.0, } } + /// A layout of glyphs `advances` wide round a circle of radius `radius` (y down), from its left, + /// outside it (their feet towards its centre) or inside it, the length of the circle between + /// their centres `fraction` of their advances' mean. + fn round_layout(advances: &[f64], radius: f64, outside: bool, fraction: f64) -> Val { + let sign = if outside { 1.0 } else { -1.0 }; + let (mut xs, mut ys, mut matrices, mut ends) = (vec![], vec![], vec![], vec![0.0]); + let mut angle = std::f64::consts::PI; + for (i, a) in advances.iter().enumerate() { + if i > 0 { + angle += sign * (advances[i - 1] + a) * 0.5 * fraction / radius; + } + // Outside, clockwise on the page (y down); inside, the other way round. + let along = (-angle.sin() * sign, angle.cos() * sign); + let centre = (radius * angle.cos(), radius * angle.sin()); + xs.push(centre.0 - along.0 * a * 0.5); + ys.push(centre.1 - along.1 * a * 0.5); + matrices.extend([along.0, along.1, -along.1, along.0, 0.0, 0.0]); + ends.push(ends[i] + a); + } + let nums = |v: &[f64]| v.iter().map(|n| format!("{n}")).collect::>().join(" "); + let doc = format!( + "/0 << /99 /F /6 [ << /99 /L /6 [ << /99 /S /6 [ << /99 /G /21 << /1 [ {} ] /2 [ {} ] /3 [ {} ] /4 [ {} ] >> >> ] >> ] >> ] >>", + nums(&xs), + nums(&ys), + nums(&matrices), + nums(&ends) + ); + read(doc.as_bytes()).unwrap().get("0").unwrap().clone() + } + + /// The file lays type on a path out closer round the outside of a curve and further apart round + /// the inside: the spacing that does that is read from its layout, as the text engine's model + /// has it (length + turn × spacing = advance). + #[test] + fn the_layout_of_type_on_a_path_gives_its_spacing() { + let advances = [20.0, 30.0, 10.0, 25.0, 20.0]; + // Outside a circle of radius 100, 10 above the path: the centres are 100/110 of the advances apart. + let spacing = path_spacing(&round_layout(&advances, 100.0, true, 100.0 / 110.0)); + assert!((spacing - 10.0).abs() < 1e-6, "{spacing}"); + // Inside it, 20 above the path: 100/80 of them. + let spacing = path_spacing(&round_layout(&advances, 100.0, false, 100.0 / 80.0)); + assert!((spacing - 20.0).abs() < 1e-6, "{spacing}"); + // At their advances: none. + assert!(path_spacing(&round_layout(&advances, 100.0, true, 1.0)).abs() < 1e-6); + // Corners (here a turn of about four radians from glyph to glyph) aren't taken for a curve. + assert_eq!(path_spacing(&round_layout(&advances, 5.0, true, 1.0)), 0.0); + // A straight line turns nowhere, and a layout without glyphs says nothing. + assert_eq!(path_spacing(&round_layout(&advances, 1e12, true, 0.9)), 0.0); + assert_eq!(path_spacing(&read(b"/99 /F").unwrap()), 0.0); + } + /// The leading of the text object a story comes in as. fn leading_of(story: &Story) -> Option { let node = story.node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY)?; diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index c36013cb8..8e80703d7 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -1,8 +1,8 @@ -use super::compare::{MAX_DIFFERENCE, PageView, compare}; +use super::compare::{MAX_DIFFERENCE, PageView, compare, difference}; use super::plan::plan; use super::slots::{ ART_LEFT_OUT, Content, MAX_PAIRS, MAX_SLOTS, MAX_TEXT, OUTLINE_REACH, TEXT_LEFT_OUT, TYPE_LEFT_OUT, collect_slots, extent, few, fill_slots, - text_objects, + letters, text_objects, words, }; use super::{Affine, Arc, BTreeSet, Document, Node, NodeId, NodeKind, PathData, Rect, Scaling, Story, Structure, Texts, Unit, Vec2, ai}; @@ -85,6 +85,10 @@ pub(super) fn build_from( // scale, which `to_doc` carries, so its art and the page reach the same size (#2031). let fits = |r: &Rect| (r.width() - page_rect.width()).abs() <= 0.5 && (r.height() - page_rect.height()).abs() <= 0.5; let mut rect = to_doc.transform_rect_bbox(art_box); + // A PDF saved with some of its artboards has pages for those only, the first of them not + // always the first artboard: the editing data keeps them all (`first`, the page's). + let first = shown_artboard(&doc, visible, page, page_rect, fits); + rect = doc.artboards.get(first).filter(|_| first > 0).map_or(rect, |a| a.rect); // A `.ai` whose PDF part's pages are cropped to its art (each page the box of all of it, // the editing data's bounding box) rather than to its artboards: the layers are compared // with the page on that box, and keep their artboards. Each page then holds all the art @@ -127,9 +131,7 @@ pub(super) fn build_from( // 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. let pages: Vec = visible.artboards.iter().map(|a| a.rect).collect(); - 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 boards = (pages.len() == doc.artboards.len() && doc.artboards.iter().zip(&pages).all(same)).then_some(&doc.artboards); + let boards = page_boards(&doc.artboards, first, &pages); // The page with most of an object, the first for what is on none. let page_of = |object: &[Arc]| { extent(object).and_then(|r| { @@ -178,9 +180,9 @@ pub(super) fn build_from( if outlined && infos.iter().any(|i| i.shown) { return Err("its type is to come in as outlines, which only its page has".into()); } - if infos.iter().all(|i| !i.shown) && !shown.is_empty() { - return Err("its page has text, and its layers have no text objects to put it in".into()); - } + // The page's type that needs no text object of the layers: the layers' own legacy text, or + // placed files that show it. + let as_outlines = page_type_elsewhere(infos.is_empty(), infos.iter().all(|i| !i.shown), &mut shown, &mut hidden, &doc, &placed_elsewhere)?; // The page's type is compared with every text object of the layers: that has its limits. if infos.len() > MAX_SLOTS || infos.len().saturating_mul(shown.len()) > MAX_PAIRS { return Err("it has too many text objects".into()); @@ -228,6 +230,9 @@ pub(super) fn build_from( if short > 0 { notes.push(format!("{short} {PAGE_TYPE_WITHOUT_OUTLINES}")); } + if as_outlines > 0 { + notes.push(format!("{as_outlines} {PAGE_TYPE_AS_OUTLINES}")); + } if !hidden_unread.is_empty() { notes.push(format!("{ART_LEFT_OUT} ({}), left out of them", few(hidden_unread))); } @@ -242,7 +247,22 @@ pub(super) fn build_from( let unseen = prune_unseen(&frame, &mut view, &mut doc); let unseen_note = |n: usize| (n > 0).then(|| format!("{n} {UNSEEN_TEXT}")); match compare(&frame, &mut view, &doc) { - Ok(note) => notes.extend(unseen_note(unseen).into_iter().chain(note)), + // Placed files at the other point their data gives, where that draws closer to the + // page: a small image in the wrong place differs too little to fail. + Ok(note) => { + let closer = other.and_then(|mut other| { + let unseen = prune_unseen(&frame, &mut view, &mut other); + let closer = view.difference(&other, frame.rect).any < view.difference(&doc, frame.rect).any; + closer.then(|| compare(&frame, &mut view, &other).ok()).flatten().map(|note| (other, unseen, note)) + }); + match closer { + Some((other, unseen, note)) => { + doc = other; + notes.extend(unseen_note(unseen).into_iter().chain(note)); + } + None => notes.extend(unseen_note(unseen).into_iter().chain(note)), + } + } // Placed files at the other point their data gives, where that draws as the page. Err(why) => match other.map(|mut other| { let unseen = prune_unseen(&frame, &mut view, &mut other); @@ -284,12 +304,108 @@ fn elsewhere(doc: &Document, placed: &[(NodeId, Affine)]) -> Option { Some(doc) } +/// The page's type that needs no text object of the layers, taken out of `shown` and `hidden` (the +/// page's text objects that show and that don't): how many of them the layers have as outlines. +/// Type in the legacy format (`To` … `TO`, as files saved for Illustrator 10 and earlier keep it) +/// is read as text objects of the layers' own, with no slot (`no_slots`) for the page's type; the +/// page's type that their words don't hold is outlines on the layers, and the comparison with the +/// page says whether they draw it. Without such text and with no slot that shows (`none_shown`), +/// the page's type must all lie inside placed files, which show it. +fn page_type_elsewhere( + no_slots: bool, + none_shown: bool, + shown: &mut Vec>>, + hidden: &mut Vec>>, + doc: &Document, + placed: &[(NodeId, Affine)], +) -> Result { + let mut own = vec![]; + own_text(&doc.layers, &mut own); + let mut as_outlines = 0; + if no_slots && !own.is_empty() { + as_outlines = shown.iter().filter(|o| !in_own_text(o, &own)).count(); + shown.clear(); + hidden.clear(); + } + if none_shown && !shown.is_empty() && own.is_empty() { + if !in_placed_files(shown, doc, placed) { + return Err("its page has text, and its layers have no text objects to put it in".into()); + } + // The page's type is all inside placed files: it is theirs, which their previews show, and + // needs no text object. + shown.clear(); + } + Ok(as_outlines) +} + +/// Is the page's text object `object` the layers' own text (`own`, from [`own_text`]): one with its +/// words next to it? The rest of the page's type is what the editing data keeps as outlines (what +/// the legacy format can't hold), and the comparison with the page says whether the layers draw it. +fn in_own_text(object: &[Arc], own: &[(Rect, String)]) -> bool { + let Some(r) = extent(object) else { return false }; + let near: String = + own.iter().filter(|(b, _)| !b.inflate(OUTLINE_REACH, OUTLINE_REACH).intersect(r).is_zero_area()).map(|(_, l)| l.as_str()).collect(); + let page_words: Vec = object + .iter() + .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(words(&t.plain_text().to_lowercase())) } else { None }) + .flatten() + .collect(); + !near.is_empty() && page_words.iter().all(|w| near.contains(w.as_str())) +} + +/// The text objects of their own (not slots for the page's type) that show in `nodes`: their +/// bounds and their letters, in lower case. +fn own_text(nodes: &[Arc], out: &mut Vec<(Rect, String)>) { + for n in nodes.iter().filter(|n| n.visible) { + match &n.kind { + NodeKind::Text(t) => out.extend(n.visual_bounds().map(|b| (b, letters(&t.plain_text().to_lowercase())))), + NodeKind::Compound { .. } => {} + _ => { + if let Some(c) = n.children() { + own_text(c, out); + } + } + } + } +} + /// Has `r` the size of `page`'s first artboard (in points)? fn same_size(r: Option, page: &Document) -> bool { let (Some(r), Some(p)) = (r, page.artboards.first().map(|a| a.rect)) else { return false }; (r.width() - p.width()).abs() <= 0.5 && (r.height() - p.height()).abs() <= 0.5 } +/// Does all of `objects` (the page's text objects) lie inside placed files that show in `doc`? The +/// page draws a placed file, type and all, where the layers show its preview (a linked image, see +/// `ai::Reader::end_place`), at its first placement or at the other one that `placed` lists (see +/// [`elsewhere`]). +fn in_placed_files(objects: &[Vec>], doc: &Document, placed: &[(NodeId, Affine)]) -> bool { + let inside = |d: &Document| { + let mut files = vec![]; + placed_files(&d.layers, &mut files); + !files.is_empty() + && objects + .iter() + .all(|o| extent(o).is_some_and(|r| files.iter().any(|f: &Rect| f.inflate(OUTLINE_REACH, OUTLINE_REACH).contains_rect(r)))) + }; + inside(doc) || elsewhere(doc, placed).is_some_and(|other| inside(&other)) +} + +/// Where the placed files that show in `nodes` are: the linked images the editing data's reader +/// makes of them. +fn placed_files(nodes: &[Arc], out: &mut Vec) { + for n in nodes.iter().filter(|n| n.visible) { + match &n.kind { + NodeKind::Image(im) if im.link.is_some() => out.extend(n.visual_bounds()), + _ => { + if let Some(c) = n.children() { + placed_files(c, out); + } + } + } + } +} + /// Does the editing data's first artboard `board` (in the document read from it: art space through /// `to_doc`) have the size of `page`'s first artboard in units of `unit` points, `unit` being above /// 1, and not its size in points? `page` is a PDF part read in points from pages of `unit` points a @@ -300,6 +416,40 @@ 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. +const MAX_BOARDS_WEIGHED: usize = 16; + +/// 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). +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(); + if let [only] = sized.as_slice() { + return *only; + } + let mut best: Option<(usize, f64)> = None; + for (i, a) in sized.iter().filter_map(|&i| Some((i, doc.artboards.get(i)?))) { + let d = difference(visible, page_rect, doc, a.rect).any; + if best.is_none_or(|(_, b)| d < b) { + best = Some((i, d)); + } + } + best.map_or(0, |(i, _)| i) +} + +/// 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) +} + /// Draw `doc`'s layers and artboards `k` times as large, from the origin, with their strokes, /// effects and pattern tiles. fn scale_document(doc: &mut Document, k: f64) { @@ -327,6 +477,9 @@ const UNSEEN_TEXT: &str = "text objects that the file keeps but its page doesn't /// left out. const PAGE_TYPE_WITHOUT_OUTLINES: &str = "text objects hold their type as the page draws it, without the characters the page has only as outlines"; +/// The page's text objects that the layers have as outlines, as the editing data keeps them. +const PAGE_TYPE_AS_OUTLINES: &str = "text objects of the page are outlines on the layers, as the file's editing data keeps them"; + /// How much more of the page (0 to 1) the layers may differ without a text object and still look no worse. const UNSEEN_MARGIN: f64 = 5e-5; diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index d4699b06d..0f34869b7 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -192,6 +192,33 @@ fn text_and_its_strokes_come_from_the_page_into_the_layer_of_its_text_object() { assert!(r.warnings.is_empty(), "{:?}", r.warnings); } +/// A point type object in the legacy format (`To` … `TO`), as an EPS saved for Illustrator 10 +/// keeps its type in the editing data: no `/AI11Text` for the page's type to go in. +fn legacy_text(x: u32, y: u32, text: &str) -> String { + format!("0 To\n1 0 0 1 {x} {y} 0 Tp\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n({text}) Tx\nTO") +} + +#[test] +fn type_in_the_legacy_format_is_the_layers_own_and_the_page_has_text() { + let page = format!("{}\n0 0 0 1 setcmykcolor /Helvetica findfont 12 scalefont setfont 10 50 moveto (Hi) show", page_square(10, 10)); + let art = native(&(layer("Art", true, &square(10, 10)) + &layer("Words", true, &legacy_text(10, 50, "Hi")))); + let r = import(&eps(&page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{:?}", r.warnings); + assert_eq!(texts[0].plain_text(), "Hi"); + assert!(!r.warnings.iter().any(|w| w.contains("left out") || w.contains("weren't read")), "{:?}", r.warnings); + // Type the editing data keeps as outlines (here a stand-in, a small square where the page has + // more type) comes in as they are, and the import says so. + let page = format!("{page}\n/Helvetica findfont 4 scalefont setfont 60 20 moveto (o) show"); + let outlines = format!("{}\n0 0 0 1 k\n60.5 20 m\n62.5 20 L\n62.5 22 L\n60.5 22 L\nf", legacy_text(10, 50, "Hi")); + let art = native(&(layer("Art", true, &square(10, 10)) + &layer("Words", true, &outlines))); + let r = import(&eps(&page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings); + assert_eq!(kinds(&r.document.layers[1]), ["text", "path"], "{:?}", r.warnings); + assert!(r.warnings.iter().any(|w| w.starts_with("1 text objects of the page are outlines on the layers")), "{:?}", r.warnings); +} + #[test] fn a_damaged_editing_copy_leaves_the_page() { // Not ASCII85, ASCII85 of something that isn't Zstandard, Zstandard of something that isn't art. @@ -266,6 +293,57 @@ fn an_ai_file_with_an_image_too_large_for_its_editing_data_comes_in_as_its_pdf_p assert!(read, "{warnings:?}"); } +/// A `.ai` whose layers hold a placed PDF and no text object: its PDF part draws the placed file, +/// type and all, where the layers show the file's preview. That type is the placed file's and needs +/// no text object, so the file comes in from its layers, the placed file where its matrix takes the +/// canvas point of art space's origin or at the corner of its box (see +/// `a_placed_file_at_its_boxs_corner_matches_the_page`). Type outside the placed file still needs +/// one, and without it the file comes in as its PDF part. +#[test] +fn type_of_the_page_inside_a_placed_file_needs_no_text_object() { + // A file placed on (30, 30)–(70, 70) whose preview is 2 × 2 pixels of half-transparent black, + // as in `a_placed_file_at_its_boxs_corner_matches_the_page`; with the template box, the canvas + // point of art space's origin puts it off the page, and the box's corner on it. + let place = format!( + "%AI5_BeginPlace\n%%FileType: 1\n[1 0 0 -1 -7970 8070] 8000 8000 8040 8040 4 4 0\n(/files/pages.pdf)`\n\ + %AI26_BeginPlacedObjectPreview\n2 2 8 32 17 {}\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n", + "\x7f\0\0\0".repeat(4) + ); + let at_origin = native(&layer("Pages", true, &place)); + let at_corner = at_origin.replace("%AI5_NumLayers", "%AI3_TemplateBox: 100 50 100 50\n%AI5_NumLayers"); + // The PDF part: the placed file's page in 50% grey, and type at (`x`, 55). + let pdf = |x: f64| { + let mut d = Document::new(100.0, 100.0); + let grey = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::gray(0.5))); + let rect = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 70.0, 70.0)); + let page = Node::path( + d.alloc_id(), + rect, + vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(grey)], ..Default::default() }, + ); + let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() }; + let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(x, 55.0), "Hi", style); + let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(text))); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, page).unwrap(); + d.insert(Some(layer), 1, text).unwrap(); + d + }; + for data in [&at_origin, &at_corner] { + let (d, warnings, read) = crate::layered_ai(&private(data), pdf(38.0), vec![], false, false, 1.0); + assert!(read, "{warnings:?}"); + assert_eq!(names(&d), ["Pages"]); + let mut links = vec![]; + d.visit_images(|_, im| links.extend(im.link.as_ref().map(|l| l.path.clone()))); + assert_eq!(links, ["/files/pages.pdf"], "{warnings:?}"); + assert!(warnings.iter().all(|w| !w.contains("no text object")), "the type is the placed file's, not left out: {warnings:?}"); + // Type beside the placed file is a text object's, which the layers don't have. + let (_, warnings, read) = crate::layered_ai(&private(data), pdf(5.0), vec![], false, false, 1.0); + assert!(!read, "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("its layers have no text objects")), "{warnings:?}"); + } +} + /// A palette of `[None]` and White, and a color group `Inks` holding a spot color, Red ink. const PALETTE: &str = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 1 1 1 Xa\n(White)\nPc\n1 (Inks) 1 Pg\n0.06 0.98 1 0 (Red ink) 0 x\n(Red ink)\nPc\nPB\n%AI5_EndPalette\n"; @@ -422,6 +500,36 @@ fn type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once() { assert!((e - 30.0).abs() < 1e-6 && (f + 10.0).abs() < 1e-6, "{:?}", texts[0].xf); } +/// A PDF saved with only the second of its two artboards has a page for that one, while its +/// editing data keeps both: the layers are compared with the page on that artboard, and the page's +/// type goes there. +#[test] +fn a_pdf_of_its_second_artboard_is_compared_on_that_artboard() { + // Two 100-point artboards side by side, 50 apart (art space, y up), a square on each, and a + // text object with no story in the file. + let board = |x0: i32| { + format!( + "%_/Dictionary :\n%_{x0} 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n", + x0 + 100 + ) + }; + let boards = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{}{}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n", board(0), board(150)); + let art = native(&(boards + &layer("Art", true, &(square(10, 10) + "\n" + &square(180, 50) + "\n" + &text_object(0))))); + // The page: the second artboard, its square 30 from the left and 46 down, and the type. + let mut pdf = pdf_part(30.0, 46.0); + let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() }; + let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(40.0, 20.0), "Hi", style); + let (layer_id, id) = (pdf.layers[0].id, pdf.alloc_id()); + pdf.insert(Some(layer_id), 1, Node::new(id, NodeKind::Text(Box::new(text)))).unwrap(); + let (d, w, read) = crate::layered_ai(&private(&art), pdf, vec![], false, false, 1.0); + assert!(read, "{w:?}"); + assert_eq!(d.artboards.len(), 2, "both artboards are kept"); + 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 - 190.0).abs() < 1e-6 && (f - 20.0).abs() < 1e-6, "on the second artboard: {:?}", texts[0].xf); +} + #[test] fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() { let pdf = pdf_part(10.0, 86.0); @@ -1439,6 +1547,84 @@ fn area_type_and_type_on_a_path_come_from_the_text_document() { assert!(!r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings); } +/// A story of `text` on frame 0 whose layout sets glyphs `advance` wide round the outside of a +/// circle of radius `radius`, the circle's length between their centres `fraction` of an advance. +fn round_story(text: &str, advance: f64, radius: f64, fraction: f64) -> String { + let n = text.chars().count(); + let (mut xs, mut ys, mut matrices, mut ends) = (vec![], vec![], vec![], vec![]); + for i in 0..=n { + // From the circle's left, clockwise on the canvas (y down), feet towards its centre. + let angle = std::f64::consts::PI + i as f64 * advance * fraction / radius; + let along = (-angle.sin(), angle.cos()); + xs.push(radius * angle.cos() - along.0 * advance * 0.5); + ys.push(radius * angle.sin() - along.1 * advance * 0.5); + matrices.push(format!("{} {} {} {} 0 0", along.0, along.1, -along.1, along.0)); + ends.push(i as f64 * advance); + } + let nums = |v: &[f64]| v.iter().map(|x| format!("{x}")).collect::>().join(" "); + format!( + "<< /0 << /0 ({text}) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 {n} >> ] >> \ + /6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /1 12.0 >> >> >> /1 {n} >> ] >> >> /1 << /0 [ << /0 0 >> ] \ + /2 [ << /99 /PC /6 [ << /99 /F /6 [ << /99 /L /6 [ << /99 /S /6 [ << /99 /G /21 << /1 [ {} ] /2 [ {} ] /3 [ {} ] /4 [ {} ] >> >> ] >> ] >> ] >> ] >> ] >> >>", + nums(&xs), + nums(&ys), + matrices.join(" "), + nums(&ends) + ) +} + +/// The file closes type up round the outside of a curve (its layout puts the glyphs' centres +/// closer than their advances), though the frame has no spacing of its own: the type comes in +/// spaced as the layout is, not at its advances, so it doesn't run on past where the page ends it. +#[test] +fn type_round_a_circle_is_spaced_as_the_file_lays_it_out() { + // A circle of radius 40 round the middle of the page, in four cubics from its right, clockwise + // on the page; the type from its left, over the top, round almost all of it. + let k = 40.0 * 0.552_284_75; + let points = [ + (90.0, 50.0), + (90.0, 50.0 - k), + (50.0 + k, 10.0), + (50.0, 10.0), + (50.0 - k, 10.0), + (10.0, 50.0 - k), + (10.0, 50.0), + (10.0, 50.0 + k), + (50.0 - k, 90.0), + (50.0, 90.0), + (50.0 + k, 90.0), + (90.0, 50.0 + k), + (90.0, 50.0), + ]; + let all: Vec<(f64, f64)> = points.iter().map(|(x, y)| canvas(*x, *y)).collect(); + let segment = |i: usize| (0..4).map(|j| format!("{} {}", all[i * 3 + j].0, all[i * 3 + j].1)).collect::>().join(" "); + let frame = + format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 2 /6 [ 2.0 5.99 ] >> >> >>", (0..4).map(segment).collect::>().join(" ")); + // Glyphs 5 apart, their centres 5 × 40/48 apart round the circle: spaced 8 above the path. + let doc = document_of(&[frame], &[round_story("abcdefgh", 5.0, 40.0, 40.0 / 48.0)]); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{:?}", r.warnings); + let t = texts[0]; + assert!(matches!(t.kind, vectorcraft_doc::TextKind::OnPath { .. }), "{:?}", t.kind); + assert!((t.path_spacing - 8.0).abs() < 0.05, "{}", t.path_spacing); + // Laid out, the glyphs' centres are 40/48 of their advances apart round the circle. + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let centres: Vec<(f64, f64)> = layout + .glyphs + .iter() + .map(|g| (g.origin.x + g.angle.cos() * g.advance * 0.5 - 50.0, g.origin.y + g.angle.sin() * g.advance * 0.5 - 50.0, g.advance)) + .map(|(x, y, a)| (y.atan2(x), a)) + .collect(); + assert_eq!(centres.len(), 8); + for w in centres.windows(2) { + let turn = (w[1].0 - w[0].0 + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; + let ratio = turn.abs() * 40.0 / ((w[0].1 + w[1].1) * 0.5); + assert!((ratio - 40.0 / 48.0).abs() < 0.01, "{ratio}"); + } +} + #[test] fn threaded_area_type_flows_through_its_frames() { let frames = [ @@ -1677,6 +1863,31 @@ fn a_placed_file_at_its_boxs_corner_matches_the_page() { assert!([b.x0 - 30.0, b.y0 - 30.0, b.x1 - 70.0, b.y1 - 70.0].iter().all(|v| v.abs() < 0.01), "on the page's square: {b:?}"); } +#[test] +fn a_small_placed_file_goes_where_it_draws_closer_to_the_page() { + // A 2 × 2 placed file (a small logo on a large page) whose box corner the matrix takes to + // (30, 30)–(32, 32), as the page draws it. The template box's canvas point puts it at + // (10, 10)–(12, 12): on the page too, and small enough that the layers pass the comparison + // there all the same. + let place = format!( + "%AI5_BeginPlace\n%%FileType: 1\n[1 0 0 -1 -7970 8070] 8000 8000 8002 8002 4 4 0\n(logo.png)`\n\ + %AI26_BeginPlacedObjectPreview\n2 2 8 32 17 {}\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n", + "\x7f\0\0\0".repeat(4) + ); + let data = native(&layer("Jig", true, &place)).replace("%AI5_NumLayers", "%AI3_TemplateBox: 211.5 -211.5 211.5 -211.5\n%AI5_NumLayers"); + let mut page = Document::new(100.0, 100.0); + let id = page.alloc_id(); + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(30.0, 30.0, 32.0, 32.0)); + let grey = vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::gray(0.5))); + let n = Node::path(id, path, vectorcraft_doc::Appearance { items: vec![vectorcraft_doc::AppearanceItem::Fill(grey)], ..Default::default() }); + let l = page.layers[0].id; + page.insert(Some(l), 0, n).unwrap(); + let (d, w, read) = crate::layered_ai(&private(&data), page, vec![], false, false, 1.0); + assert!(read, "{w:?}"); + let b = d.layers[0].children().unwrap()[0].geometric_bounds().unwrap(); + assert!([b.x0 - 30.0, b.y0 - 30.0, b.x1 - 32.0, b.y1 - 32.0].iter().all(|v| v.abs() < 0.01), "on the page's square: {b:?}"); +} + #[test] fn text_a_placed_file_covers_only_at_its_first_placement_is_kept() { const OPAQUE: &str = ""; diff --git a/crates/format/tests/prop_format.rs b/crates/format/tests/prop_format.rs index 6b3d0e8cf..faecc37a8 100644 --- a/crates/format/tests/prop_format.rs +++ b/crates/format/tests/prop_format.rs @@ -171,6 +171,27 @@ fn rich_document_roundtrips() { assert_eq!(v["version"], vectorcraft_format::VERSION); } +/// A file's Path Effects are its own business: whatever their parameters hold, they load and save +/// as they are (the engine's tests draw them). +#[test] +fn path_effects_with_junk_params_round_trip() { + let mut d = rich_doc(); + let mut id = None; + d.walk(|n| id = id.or(n.path_data().map(|_| n.id))); + let id = id.unwrap(); + let effects: Vec = [ + ("addon.path.dashes", json!({"dash1": 1e308, "gap1": -1e308, "offset": "x", "alignCorners": 3})), + ("addon.path.addAnchors", json!({"count": 1e308, "maxCount": 1e308, "distance": -1e308, "spacing": "distance", "target": []})), + ("addon.path.extend", json!(null)), + ] + .into_iter() + .map(|(id, params)| vectorcraft_doc::Effect { id: id.into(), params, visible: true }) + .collect(); + d.node_mut(id).unwrap().appearance.effects = effects.clone(); + let back = load(&save(&d, true)).unwrap(); + assert_eq!(back.node(id).unwrap().appearance.effects, effects); +} + #[test] fn sniff_distinguishes_formats() { assert!(sniff(&save(&Document::new(1.0, 1.0), false))); diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index c16e0023a..6b1e9f6b0 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -192,7 +192,9 @@ impl Headless { let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?; let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base); - let ev = PointerEvent { kind, pos: Point::new(x, y), mods, pressure: PointerEvent::json_pressure(e) }; + let backquote = + e.get("backquote").and_then(Value::as_bool).unwrap_or_else(|| p.get("backquote").and_then(Value::as_bool).unwrap_or(false)); + let ev = PointerEvent { kind, pos: Point::new(x, y), mods, backquote, pressure: PointerEvent::json_pressure(e) }; let reqs = self.session.pointer(&ev, self.view).map_err(|e| e.to_string())?; self.apply_ui_requests(reqs, &mut requests)?; // With no frames to show the highlight in later, a hover waits for the work the tool diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 06ce7e598..7192d8208 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1398,6 +1398,30 @@ fn selection_preferences_apply_to_pointer_gestures() { assert_eq!(click(&mut s, 7, 250.0, json!({})), json!([front]), "the path does"); } +#[test] +fn pointer_gesture_forwards_backquote_on_each_event() { + let mut s = server(); + let made = call(&mut s, 1, "draw_shape", json!({"shape": "rectangle", "x": 100, "y": 100, "width": 50, "height": 50, "fill": "#ff0000"})); + let id = serde_json::from_str::(&text_of(&made)).unwrap()["id"].as_u64().unwrap(); + let selected = call(&mut s, 2, "run_command", json!({"command": "select.set", "params": {"ids": [id]}})); + assert_eq!(selected["isError"], false, "{selected}"); + let drag = call( + &mut s, + 3, + "pointer_gesture", + json!({"tool": "selection", "backquote": true, "events": [ + {"kind": "down", "x": 125, "y": 125}, + {"kind": "drag", "x": 145, "y": 125}, + {"kind": "up", "x": 145, "y": 125} + ]}), + ); + assert_eq!(drag["isError"], false, "{drag}"); + let inspected = call(&mut s, 4, "inspect_document", json!({})); + let doc = serde_json::from_str::(&text_of(&inspected)).unwrap(); + let rect = doc["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == id).unwrap(); + assert_eq!(rect["bounds"]["x"], 100.0, "{doc}"); +} + /// A Shift-drag marquee reaches agents (#483): `pointer_gesture` with `mods.shift` toggles the /// objects it reaches, so a selected one leaves the selection and an unselected one joins it. #[test] diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 154bf18c3..760e723ce 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -209,11 +209,13 @@ pub fn tool_definitions() -> Vec { "x": num("Document x (pt)"), "y": num("Document y (pt)"), "mods": mods_schema(), + "backquote": {"type": "boolean", "description": "Backquote held for this event: Selection and transform tool drags transform only pattern paint"}, "pressure": num("Pen pressure 0..1 (default 1): the Liquify tools' intensity with Use Pressure Pen on"), "holdMs": num("Milliseconds the pointer then holds still, button down (0..60000): Twirl, Pucker and Bloat keep applying"), }), &["kind", "x", "y"]), }, "mods": mods_schema(), + "backquote": {"type": "boolean", "description": "Default backquote held state for events: Selection and transform tool drags transform only pattern paint"}, }), &["events"], ), diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs index 42d497c1f..9310b12be 100644 --- a/crates/mcp/src/tools/dispatch.rs +++ b/crates/mcp/src/tools/dispatch.rs @@ -141,6 +141,9 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result j(draw_path(b, a)?), diff --git a/crates/pathops/src/addons.rs b/crates/pathops/src/addons.rs index f85ebae67..3896d02d0 100644 --- a/crates/pathops/src/addons.rs +++ b/crates/pathops/src/addons.rs @@ -5,7 +5,8 @@ //! - [`cleanup`]: Artwork Cleanup's path repairs (collinear, duplicate and near anchors, tiny //! handles, nearly closed paths) and Rejoin Path Pieces. //! - [`pathtools`]: Path Precision Tools' anchor edits, path readouts, extending open paths, -//! finding where paths cross and the Parametric Shape Tool's re-editable shapes. +//! finding where paths cross, sliding anchors along their path, merging touching edges and the +//! Parametric Shape Tool's re-editable shapes. //! - [`segment_reshape`]: bending a segment by dragging a point on it (shared by the tools that //! reshape segments directly). //! - [`pen`]: the Smart Pen's straight and curved segments and its point edits. @@ -19,5 +20,5 @@ pub mod segment_reshape; pub use cleanup::{ Rejoined, close_nearly_closed, merge_duplicate_anchors, near_point_groups, rejoin, remove_collinear_anchors, retract_tiny_handles, }; -pub use keep_shape::{SHARP_CORNER_DEG, remove_anchor_keep_shape, simplify_anchors_keep_shape, simplify_keep_shape}; +pub use keep_shape::{PT_PER_TOLERANCE, SHARP_CORNER_DEG, remove_anchor_keep_shape, simplify_anchors_keep_shape, simplify_keep_shape}; pub use segment_reshape::{ReshapeMode, reshape_segment}; diff --git a/crates/pathops/src/addons/keep_shape.rs b/crates/pathops/src/addons/keep_shape.rs index 396795ebb..a3721f8f2 100644 --- a/crates/pathops/src/addons/keep_shape.rs +++ b/crates/pathops/src/addons/keep_shape.rs @@ -13,6 +13,9 @@ use crate::fit::{end_tangent, start_tangent}; /// The turn (degrees) at an anchor above which `protect_corners` keeps it. pub const SHARP_CORNER_DEG: f64 = 30.0; +/// The drift (pt) each step of the tolerance the add-ons offer allows. +pub const PT_PER_TOLERANCE: f64 = 0.02; + /// Points sampled along each curve to measure how far two outlines are apart. const SAMPLES: usize = 8; diff --git a/crates/pathops/src/addons/pathtools.rs b/crates/pathops/src/addons/pathtools.rs index e83666413..8e0474c9a 100644 --- a/crates/pathops/src/addons/pathtools.rs +++ b/crates/pathops/src/addons/pathtools.rs @@ -1,21 +1,29 @@ //! Path Precision Tools' geometry: anchor edits for the Anchor Inspector (smooth by the auto-smooth -//! rule, equalise handles, add evenly spaced anchors, close), the readouts the Measure & Inspect -//! tool shows over a path, open paths lengthened or shortened at an end ([`extend`]) and the places -//! where paths cross ([`crossings`]), and the Parametric Shape Tool's shapes ([`shapes`]). +//! rule, equalise handles, add evenly spaced anchors, close), what its live path effects add to them +//! ([`add_anchors`], [`smooth_anchors`], [`split_at_anchors`]), the readouts the Measure & Inspect +//! tool shows over a path, open paths lengthened or shortened at an end ([`extend`]), the places +//! where paths cross ([`crossings`]), anchors slid along their path ([`slide`]), touching edges +//! merged ([`shared_edge`]) and the Parametric Shape Tool's shapes ([`shapes`]). mod crossings; mod extend; pub mod shapes; +mod shared_edge; +mod slide; #[cfg(test)] mod tests; use std::collections::BTreeSet; use kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveNearest, Point, Vec2}; -use vectorcraft_geom::{AnchorKind, PathData, SubPath}; +use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath}; pub use crossings::{MAX_CROSSING_SEGMENTS, crossings, insert_at}; pub use extend::{ExtendMode, extend_length_to, extend_open}; +pub use shared_edge::{ + COINCIDENCE, EndSide, Follow, MAX_COINCIDENCE, MergePlan, MergePreview, Stretch, apply_merge, ends_at, merge_preview, stretches_at, +}; +pub use slide::{MAX_SLIDE, slide_anchor, slide_stops, slide_t_at}; use crate::fit::{end_tangent, start_tangent}; @@ -148,6 +156,69 @@ pub fn add_points(sp: &SubPath, segments: Option<&BTreeSet>, count: usize insert_at(sp, &hits) } +/// The segments [`add_anchors`] adds to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SegmentKind { + All, + Curved, + Straight, +} + +/// How [`add_anchors`] spaces its anchors along a segment. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum AnchorSpacing { + /// This many, evenly spaced by length. + Count(usize), + /// One every this many points from the segment's start, none on its end. + Distance(f64), +} + +/// `sp` with anchors added on the segments `which` picks, spaced as `spacing` says and at most `cap` +/// on one segment; the shape doesn't change. `None` when that would add more than `room` anchors. +pub fn add_anchors(sp: &SubPath, which: SegmentKind, spacing: AnchorSpacing, cap: usize, mut room: usize) -> Option { + let mut hits = vec![]; + for seg in 0..sp.segment_count() { + let picked = match which { + SegmentKind::All => true, + SegmentKind::Curved => !sp.segment_is_line(seg), + SegmentKind::Straight => sp.segment_is_line(seg), + }; + let c = sp.segment(seg); + let len = c.arclen(ACCURACY); + if !picked || !(len > TINY && len.is_finite()) { + continue; + } + let (count, step) = match spacing { + AnchorSpacing::Count(n) => (n, len / (n as f64 + 1.0)), + AnchorSpacing::Distance(d) if d > 0.0 => (((len / d).ceil() as usize).saturating_sub(1), d), + AnchorSpacing::Distance(_) => (0, 0.0), + }; + let count = count.min(cap); + room = room.checked_sub(count)?; + hits.extend((1..=count).map(|k| (seg, c.inv_arclen(step * k as f64, ACCURACY)))); + } + Some(insert_at(sp, &hits).0) +} + +/// Make every anchor of `sp` smooth by the auto-smooth rule ([`auto_smooth`]). An open path's ends +/// come last, so each aims at the handle its neighbour ended up with, whichever way the path runs. +pub fn smooth_anchors(sp: &mut SubPath, ratio: f64) { + let (n, closed) = (sp.anchors.len(), sp.closed); + let ends = if closed { vec![] } else { vec![0, n.saturating_sub(1)] }; + for i in (0..n).filter(|&i| closed || (i > 0 && i + 1 < n)).chain(ends) { + auto_smooth(sp, i, ratio); + } +} + +/// `sp` cut at its `corners`, its `smooth` anchors, or both ([`SubPath::cut_at`]); the shape doesn't +/// change. An anchor is smooth when its handles stand in line, as the outline shows it, whatever +/// kind it was given. +pub fn split_at_anchors(sp: &SubPath, corners: bool, smooth: bool) -> Vec { + let is_smooth = |a: &Anchor| Anchor::with_handles(a.p, a.h_in, a.h_out).kind == AnchorKind::Smooth; + let at = sp.anchors.iter().enumerate().filter(|(_, a)| if is_smooth(a) { smooth } else { corners }).map(|(i, _)| i).collect(); + sp.cut_at(&at) +} + /// How [`close`] joins an open subpath's ends. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CloseMode { diff --git a/crates/pathops/src/addons/pathtools/crossings.rs b/crates/pathops/src/addons/pathtools/crossings.rs index 8598e4208..e1d96ed0f 100644 --- a/crates/pathops/src/addons/pathtools/crossings.rs +++ b/crates/pathops/src/addons/pathtools/crossings.rs @@ -22,7 +22,7 @@ const END: f64 = 1e-6; /// A place on a path: (subpath, segment, t). pub type Place = (usize, usize, f64); -fn control_box(c: &CubicBez) -> Rect { +pub(super) fn control_box(c: &CubicBez) -> Rect { Rect::from_points(c.p0, c.p1).union_pt(c.p2).union_pt(c.p3) } diff --git a/crates/pathops/src/addons/pathtools/extend.rs b/crates/pathops/src/addons/pathtools/extend.rs index d86e70366..567a37ef2 100644 --- a/crates/pathops/src/addons/pathtools/extend.rs +++ b/crates/pathops/src/addons/pathtools/extend.rs @@ -44,7 +44,7 @@ const MAX_RADIUS: f64 = 1e6; /// The farthest (in its parameter) [`ExtendMode::Bezier`] stretches a segment. const MAX_STRETCH: f64 = 1024.0; -fn finite(sp: &SubPath) -> bool { +pub(super) fn finite(sp: &SubPath) -> bool { let ok = |p: Point| p.x.is_finite() && p.y.is_finite(); sp.anchors.iter().all(|a| ok(a.p) && ok(a.h_in) && ok(a.h_out)) } diff --git a/crates/pathops/src/addons/pathtools/shared_edge.rs b/crates/pathops/src/addons/pathtools/shared_edge.rs new file mode 100644 index 000000000..6b4e8bfb1 --- /dev/null +++ b/crates/pathops/src/addons/pathtools/shared_edge.rs @@ -0,0 +1,489 @@ +//! Merging two touching edges: where two paths meet at two pairs of coincident anchors, the +//! stretches of path between those anchors become one shared edge ([`stretches_at`]); where the +//! anchors aren't there yet, near ones are brought together and a missing one is added +//! ([`ends_at`]). [`merge_preview`] says what a plan would do and [`apply_merge`] does it. The +//! Shared Edge Merge Tool and `addon.pathtools.mergeEdge` drive both from the same plan. + +use std::collections::BTreeMap; + +use kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveNearest, Point}; +use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath}; + +use super::crossings::control_box; +use super::extend::finite; +use super::{ACCURACY, TINY, insert_at, unit}; +use crate::fit::{end_tangent, is_straight, start_tangent}; + +#[cfg(test)] +mod tests; + +/// How near two anchors count as one, by default and at most (pt). +pub const COINCIDENCE: f64 = 0.002; +pub const MAX_COINCIDENCE: f64 = 3.0; + +/// Stretches differing in length by more than this ratio aren't the same edge. +const MAX_LENGTH_RATIO: f64 = 1.1; +/// The cosine of the widest angle (120°) stretches may make at an end: past it they run apart. +const MIN_END_COS: f64 = -0.5; +/// Paths with more anchors than this aren't searched. +const MAX_PATH_ANCHORS: usize = 200_000; +/// Most segments a stretch runs. +const MAX_STRETCH: usize = 5_000; +/// Most segments under the brush looked at on each path. +const MAX_NEAR: usize = 16; +/// Anchors of the two stretches closer than this share of the length are one anchor of the result. +const SAME_PLACE: f64 = 0.01; +/// How close (pt along a segment) a cut has to be to the segment's end to be that end. +const AT_END: f64 = 1e-6; +/// The cosine of the widest bend (5°) the average's joints are straightened out of. +const MIN_JOINT_COS: f64 = 0.9962; +/// Control points this close (pt) are the same edge. +const SAME_EDGE: f64 = 1e-6; + +/// Which outline a merge keeps. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Follow { + /// Half way between the two. + Average, + /// The first path's: it doesn't change. + First, + /// The second path's. + Second, +} + +impl Follow { + pub const ALL: [Self; 3] = [Self::Average, Self::First, Self::Second]; + + pub fn id(self) -> &'static str { + match self { + Self::Average => "average", + Self::First => "a", + Self::Second => "b", + } + } + + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|f| f.id() == s) + } +} + +/// `segments` segments of a subpath from anchor `start` forward; `reversed` when the merge runs +/// along them backward. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Stretch { + pub subpath: usize, + pub start: usize, + pub segments: usize, + pub reversed: bool, +} + +/// One path's side of making an end. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum EndSide { + /// The anchor it has there. + Anchor { subpath: usize, anchor: usize }, + /// It has none: one goes on this segment. + Insert { subpath: usize, segment: usize, t: f64 }, +} + +/// What a click can do. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum MergePlan { + /// Replace these two stretches, running the same way, with one edge. + Edge { a: Stretch, b: Stretch }, + /// Bring the two sides to one point. + Ends { a: EndSide, b: EndSide }, +} + +/// What a plan would do. +#[derive(Clone, Debug, PartialEq)] +pub enum MergePreview { + /// The two stretches and the edge replacing them; `done` when they are that edge already. + Edge { a: Vec, b: Vec, merged: Vec, done: bool }, + /// Where the two sides are and where they meet. + Ends { a: Point, b: Point, to: Point }, +} + +fn searchable(a: &PathData, b: &PathData, at: Point) -> bool { + let fits = |p: &PathData| p.anchor_count() <= MAX_PATH_ANCHORS && p.subpaths.iter().all(finite); + at.x.is_finite() && at.y.is_finite() && fits(a) && fits(b) +} + +impl Stretch { + /// Its segments in the order the merge runs along them. + fn chain(&self, path: &PathData) -> Option> { + let sp = path.subpaths.get(self.subpath)?; + let count = sp.segment_count(); + if count == 0 || self.segments == 0 || self.segments > count { + return None; + } + let forward: Vec = (0..self.segments).map(|k| sp.segment((self.start + k) % count)).collect(); + Some(if self.reversed { backward(&forward) } else { forward }) + } +} + +/// `chain` run the other way. +fn backward(chain: &[CubicBez]) -> Vec { + chain.iter().rev().map(|c| CubicBez::new(c.p3, c.p2, c.p1, c.p0)).collect() +} + +fn length(chain: &[CubicBez]) -> f64 { + chain.iter().map(|c| c.arclen(ACCURACY)).sum() +} + +/// A subpath's anchors in order of x, to find the ones at a point quickly. +struct ByX<'a> { + sp: &'a SubPath, + order: Vec<(f64, usize)>, +} + +impl<'a> ByX<'a> { + fn new(sp: &'a SubPath) -> Self { + let mut order: Vec<(f64, usize)> = sp.anchors.iter().enumerate().map(|(i, a)| (a.p.x, i)).collect(); + order.sort_by(|x, y| x.0.total_cmp(&y.0)); + Self { sp, order } + } + + /// Its anchor nearest `p`, within `tol`. + fn partner(&self, p: Point, tol: f64) -> Option { + let from = self.order.partition_point(|(x, _)| *x < p.x - tol); + let within = self.order.iter().skip(from).take_while(|(x, _)| *x <= p.x + tol); + let near = within.filter_map(|(_, i)| Some((self.sp.anchors.get(*i)?.p.distance(p), *i))).filter(|(d, _)| *d <= tol); + near.min_by(|x, y| x.0.total_cmp(&y.0)).map(|(_, i)| i) + } +} + +/// The segments of `path` within `radius` of `at`, nearest first: (distance, subpath, segment). +fn segments_near(path: &PathData, at: Point, radius: f64) -> Vec<(f64, usize, usize)> { + let mut out = vec![]; + for (si, sp) in path.subpaths.iter().enumerate() { + for s in 0..sp.segment_count() { + let c = sp.segment(s); + if !control_box(&c).inflate(radius, radius).contains(at) { + continue; + } + let d = c.nearest(at, ACCURACY).distance_sq.sqrt(); + if d <= radius { + out.push((d, si, s)); + } + } + } + out.sort_by(|x, y| x.0.total_cmp(&y.0)); + out.truncate(MAX_NEAR); + out +} + +/// The stretch of `sa` around segment `seg` between the nearest anchors each way that `b` has a +/// coincident one for, and the stretch of `b` between those that holds its segment `seg_b`. +fn matching((si_a, sa): (usize, &SubPath), seg: usize, (si_b, by_x): (usize, &ByX), seg_b: usize, tol: f64) -> Option<(Stretch, Stretch)> { + let (n, m) = (sa.anchors.len(), by_x.sp.anchors.len()); + let limit = sa.segment_count().min(MAX_STRETCH); + let partner = |i: usize| by_x.partner(sa.anchors.get(i)?.p, tol); + let back = (0..limit).take_while(|k| sa.closed || *k <= seg).find_map(|k| Some((k, partner((seg + n - k) % n)?))); + let ahead = (0..limit).take_while(|k| sa.closed || seg + 1 + k < n).find_map(|k| Some((k, partner((seg + 1 + k) % n)?))); + let ((back, b0), (ahead, b1)) = (back?, ahead?); + let segments = back + ahead + 1; + // Round to the same anchor, or to the same one of the other path: there is no stretch between. + if b0 == b1 || (sa.closed && segments >= n) { + return None; + } + let first = Stretch { subpath: si_a, start: (seg + n - back) % n, segments, reversed: false }; + let run = |from: usize, to: usize| (to + m - from) % m; + let holds = |from: usize, to: usize| (seg_b + m - from) % m < run(from, to); + let second = if (by_x.sp.closed || b0 < b1) && holds(b0, b1) { + Stretch { subpath: si_b, start: b0, segments: run(b0, b1), reversed: false } + } else if (by_x.sp.closed || b1 < b0) && holds(b1, b0) { + Stretch { subpath: si_b, start: b1, segments: run(b1, b0), reversed: true } + } else { + return None; + }; + Some((first, second)) +} + +/// Could these two run along one another: much the same length, not leaving their ends in +/// opposite directions? +fn alike(a: &[CubicBez], b: &[CubicBez]) -> bool { + let (la, lb) = (length(a), length(b)); + let ((Some(a0), Some(a1)), (Some(b0), Some(b1))) = ((a.first(), a.last()), (b.first(), b.last())) else { return false }; + la.min(lb) > TINY + && la.max(lb) <= la.min(lb) * MAX_LENGTH_RATIO + && start_tangent(a0).dot(start_tangent(b0)) >= MIN_END_COS + && end_tangent(a1).dot(end_tangent(b1)) >= MIN_END_COS +} + +/// The two stretches of `a` and `b` that can merge within `radius` of `at`, each pair of ends +/// coincident within `tol`: the pair nearest `at`. `None` when there is none. +pub fn stretches_at(a: &PathData, b: &PathData, at: Point, radius: f64, tol: f64) -> Option { + if !searchable(a, b, at) { + return None; + } + let (near_a, near_b) = (segments_near(a, at, radius), segments_near(b, at, radius)); + let index: BTreeMap = near_b.iter().filter_map(|&(_, si, _)| Some((si, ByX::new(b.subpaths.get(si)?)))).collect(); + let mut pairs: Vec<_> = near_a.iter().flat_map(|&(da, sa, ga)| near_b.iter().map(move |&(db, sb, gb)| (da + db, (sa, ga), (sb, gb)))).collect(); + pairs.sort_by(|x, y| x.0.total_cmp(&y.0)); + pairs.into_iter().find_map(|(_, (sa, ga), (sb, gb))| { + let (x, y) = matching((sa, a.subpaths.get(sa)?), ga, (sb, index.get(&sb)?), gb, tol)?; + alike(&x.chain(a)?, &y.chain(b)?).then_some(MergePlan::Edge { a: x, b: y }) + }) +} + +/// The anchor of `path` nearest `at`, within `radius`: (subpath, anchor, where). +fn anchor_near(path: &PathData, at: Point, radius: f64) -> Option<(usize, usize, Point)> { + let near = path.anchors().map(|(si, ai, a)| (a.p.distance(at), si, ai, a.p)).filter(|x| x.0 <= radius); + near.min_by(|x, y| x.0.total_cmp(&y.0)).map(|(_, si, ai, p)| (si, ai, p)) +} + +/// Where `path` takes an anchor to match one at `p`, if the place is within `radius` of `at`. +fn matching_place(path: &PathData, p: Point, at: Point, radius: f64) -> Option { + let (subpath, segment, t, q, _) = path.nearest(p)?; + (q.distance(at) <= radius).then_some(EndSide::Insert { subpath, segment, t }) +} + +/// What making an end within `radius` of `at` takes: the two paths' nearest anchors there, or the +/// anchor one has and the place on the other for its match. `None` when neither has an anchor +/// there, or they are together already. +pub fn ends_at(a: &PathData, b: &PathData, at: Point, radius: f64) -> Option { + if !searchable(a, b, at) { + return None; + } + let anchor = |(subpath, anchor, _): (usize, usize, Point)| EndSide::Anchor { subpath, anchor }; + match (anchor_near(a, at, radius), anchor_near(b, at, radius)) { + (Some(x), Some(y)) => (x.2.distance(y.2) > TINY).then(|| MergePlan::Ends { a: anchor(x), b: anchor(y) }), + (Some(x), None) => Some(MergePlan::Ends { a: anchor(x), b: matching_place(b, x.2, at, radius)? }), + (None, Some(y)) => Some(MergePlan::Ends { a: matching_place(a, y.2, at, radius)?, b: anchor(y) }), + (None, None) => None, + } +} + +/// Where the anchors between `chain`'s segments fall, as shares of its length. +fn fractions(chain: &[CubicBez]) -> Vec { + let total = length(chain); + let mut run = 0.0; + let inner = chain.iter().take(chain.len().saturating_sub(1)); + inner + .map(|c| { + run += c.arclen(ACCURACY); + run / total + }) + .collect() +} + +/// The places (share of the length on each stretch) both stretches get an anchor: each one's own +/// anchors, where two nearly meet the pair of them, and otherwise the same share on the other. +fn shared_cuts(fa: &[f64], fb: &[f64]) -> Vec<(f64, f64)> { + let (mut i, mut j) = (0, 0); + let mut out = vec![]; + loop { + match (fa.get(i), fb.get(j)) { + (Some(&x), Some(&y)) if (x - y).abs() <= SAME_PLACE => { + out.push((x, y)); + i += 1; + j += 1; + } + (Some(&x), Some(&y)) if x < y => { + out.push((x, x)); + i += 1; + } + (Some(&x), None) => { + out.push((x, x)); + i += 1; + } + (_, Some(&y)) => { + out.push((y, y)); + j += 1; + } + (None, None) => return out, + } + } +} + +/// `chain` with a joint at each of the shares of its length `at`; those at a joint already add none. +fn cut(chain: &[CubicBez], mut at: Vec) -> Vec { + at.sort_by(f64::total_cmp); + let total = length(chain); + let mut next = at.into_iter().peekable(); + let mut run = 0.0; + let mut out = vec![]; + for c in chain { + let len = c.arclen(ACCURACY); + let mut ts = vec![0.0]; + while let Some(along) = next.peek().map(|f| f * total - run).filter(|d| *d < len - AT_END) { + next.next(); + if along > AT_END { + ts.push(c.inv_arclen(along, ACCURACY)); + } + } + ts.push(1.0); + out.extend(ts.windows(2).filter_map(|w| Some(c.subsegment(*w.first()?..*w.get(1)?)))); + run += len; + } + out +} + +/// The curve half way between `a` and `b`; a straight one has its handles on its anchors. +fn halfway(a: &CubicBez, b: &CubicBez) -> CubicBez { + let c = CubicBez::new(a.p0.midpoint(b.p0), a.p1.midpoint(b.p1), a.p2.midpoint(b.p2), a.p3.midpoint(b.p3)); + if is_straight(&c, SAME_EDGE) { CubicBez::new(c.p0, c.p0, c.p3, c.p3) } else { c } +} + +/// Bring the handles at `chain`'s joints in line where they all but are: averaging leaves a small +/// bend at what was a smooth anchor on both stretches. +fn align_joints(chain: &mut [CubicBez]) { + for k in 1..chain.len() { + let (before, after) = chain.split_at_mut(k); + let (Some(x), Some(y)) = (before.last_mut(), after.first_mut()) else { continue }; + let (Some(i), Some(o)) = (unit(x.p3 - x.p2), unit(y.p1 - y.p0)) else { continue }; + if i.dot(o) < MIN_JOINT_COS { + continue; + } + let Some(dir) = unit(i + o) else { continue }; + x.p2 = x.p3 - dir * (x.p3 - x.p2).hypot(); + y.p1 = y.p0 + dir * (y.p1 - y.p0).hypot(); + } +} + +/// The edge replacing stretches `a` and `b`, which run the same way. `None` when their anchors +/// can't be matched up. +fn merged_edge(a: &[CubicBez], b: &[CubicBez], follow: Follow) -> Option> { + match follow { + Follow::First => return Some(a.to_vec()), + Follow::Second => return Some(b.to_vec()), + Follow::Average => {} + } + // A segment of no length has nothing to average. + let solid = |c: &[CubicBez]| -> Vec { c.iter().filter(|s| s.arclen(ACCURACY) > TINY).copied().collect() }; + let (a, b) = (solid(a), solid(b)); + let cuts = shared_cuts(&fractions(&a), &fractions(&b)); + let (pa, pb) = (cut(&a, cuts.iter().map(|c| c.0).collect()), cut(&b, cuts.iter().map(|c| c.1).collect())); + if pa.is_empty() || pa.len() != pb.len() { + return None; + } + let mut out: Vec = pa.iter().zip(&pb).map(|(x, y)| halfway(x, y)).collect(); + align_joints(&mut out); + Some(out) +} + +fn in_line(a: &Anchor) -> bool { + match (unit(a.h_out - a.p), unit(a.p - a.h_in)) { + (Some(o), Some(i)) => o.cross(i).abs() <= 1e-6 && o.dot(i) > 0.0, + _ => false, + } +} + +/// `sp` with `st` replaced by `chain`, which runs the way the merge does: the anchors inside are +/// `chain`'s joints and the two at the ends move to `chain`'s ends, their outer handles with them. +/// An end whose handles no longer line up turns into a corner. +fn splice(sp: &SubPath, st: Stretch, chain: &[CubicBez]) -> Option { + let own = backward(chain); + let chain = if st.reversed { own.as_slice() } else { chain }; + let (first, last) = (chain.first()?, chain.last()?); + let mut out = sp.anchors.clone(); + let start = st.start.min(out.len()); + out.rotate_left(start); + let settle = |a: &mut Anchor| { + if a.kind == AnchorKind::Smooth && !in_line(a) { + a.kind = AnchorKind::Corner; + } + }; + let a = out.first_mut()?; + a.translate(first.p0 - a.p); + a.h_out = first.p1; + settle(a); + let b = out.get_mut(st.segments)?; + b.translate(last.p3 - b.p); + b.h_in = last.p2; + settle(b); + let joints = chain.windows(2).filter_map(|w| match w { + [x, y] => { + let mut a = Anchor { p: y.p0, h_in: x.p2, h_out: y.p1, kind: AnchorKind::Corner }; + if in_line(&a) { + a.kind = AnchorKind::Smooth; + } + Some(a) + } + _ => None, + }); + out.splice(1..st.segments, joints); + // Back in its place; a stretch over the start of a closed subpath only shifts where it starts. + let start = start.min(out.len()); + out.rotate_right(start); + Some(SubPath::new(out, sp.closed)) +} + +/// Where a side of an end is now. +fn side_at(path: &PathData, side: EndSide) -> Option { + match side { + EndSide::Anchor { subpath, anchor } => Some(path.subpaths.get(subpath)?.anchors.get(anchor)?.p), + EndSide::Insert { subpath, segment, t } => { + let sp = path.subpaths.get(subpath)?; + (segment < sp.segment_count()).then(|| sp.segment(segment).eval(t.clamp(0.0, 1.0))) + } + } +} + +/// Where the two sides of an end meet: on the path followed; with neither, half way between two +/// anchors, or at the anchor when the other side is a place on a path. +fn meeting_point((a, sa): (Point, EndSide), (b, sb): (Point, EndSide), follow: Follow) -> Point { + match (follow, sa, sb) { + (Follow::First, ..) | (Follow::Average, EndSide::Anchor { .. }, EndSide::Insert { .. }) => a, + (Follow::Second, ..) | (Follow::Average, EndSide::Insert { .. }, EndSide::Anchor { .. }) => b, + _ => a.midpoint(b), + } +} + +/// `path` with `side`'s anchor (added if it lacks one) at `to`. +fn put_side(path: &PathData, side: EndSide, to: Point) -> Option { + let mut out = path.clone(); + let anchor = match side { + EndSide::Anchor { subpath, anchor } => out.anchor_mut(subpath, anchor)?, + EndSide::Insert { subpath, segment, t } => { + let sp = out.subpaths.get_mut(subpath)?; + let (new, added) = insert_at(sp, &[(segment, t)]); + *sp = new; + sp.anchors.get_mut(*added.first()?)? + } + }; + anchor.translate(to - anchor.p); + Some(out) +} + +fn same_edge(x: &[CubicBez], y: &[CubicBez]) -> bool { + let near = |p: Point, q: Point| p.distance(q) <= SAME_EDGE; + x.len() == y.len() && x.iter().zip(y).all(|(c, d)| near(c.p0, d.p0) && near(c.p1, d.p1) && near(c.p2, d.p2) && near(c.p3, d.p3)) +} + +/// What `plan` would do to `a` and `b`, following `follow`. +pub fn merge_preview(a: &PathData, b: &PathData, plan: &MergePlan, follow: Follow) -> Option { + match *plan { + MergePlan::Edge { a: sa, b: sb } => { + let (ca, cb) = (sa.chain(a)?, sb.chain(b)?); + let merged = merged_edge(&ca, &cb, follow)?; + let done = same_edge(&merged, &ca) && same_edge(&merged, &cb); + Some(MergePreview::Edge { a: ca, b: cb, merged, done }) + } + MergePlan::Ends { a: ea, b: eb } => { + let (pa, pb) = (side_at(a, ea)?, side_at(b, eb)?); + Some(MergePreview::Ends { a: pa, b: pb, to: meeting_point((pa, ea), (pb, eb), follow) }) + } + } +} + +/// `a` and `b` as `plan` leaves them, following `follow`: each subpath stays closed or open, and +/// the stretch of the path followed doesn't change. `None` when the plan doesn't fit them. +pub fn apply_merge(a: &PathData, b: &PathData, plan: &MergePlan, follow: Follow) -> Option<(PathData, PathData)> { + match (*plan, merge_preview(a, b, plan, follow)?) { + (MergePlan::Edge { a: sa, b: sb }, MergePreview::Edge { merged, .. }) => { + let replaced = |path: &PathData, st: Stretch, keep: Follow| { + let mut out = path.clone(); + if follow != keep { + let sp = out.subpaths.get_mut(st.subpath)?; + *sp = splice(sp, st, &merged)?; + } + Some(out) + }; + Some((replaced(a, sa, Follow::First)?, replaced(b, sb, Follow::Second)?)) + } + (MergePlan::Ends { a: ea, b: eb }, MergePreview::Ends { to, .. }) => Some((put_side(a, ea, to)?, put_side(b, eb, to)?)), + _ => None, + } +} diff --git a/crates/pathops/src/addons/pathtools/shared_edge/tests.rs b/crates/pathops/src/addons/pathtools/shared_edge/tests.rs new file mode 100644 index 000000000..38e7c49e3 --- /dev/null +++ b/crates/pathops/src/addons/pathtools/shared_edge/tests.rs @@ -0,0 +1,270 @@ +use kurbo::{ParamCurve, Point}; +use vectorcraft_geom::{Anchor, PathData, SubPath}; + +use super::*; + +fn pt(x: f64, y: f64) -> Point { + Point::new(x, y) +} + +/// A closed shape above y = 0 whose lower edge runs right to left from (100, 0) to (0, 0), curved +/// down by `bulge` control-point height. +fn above(bulge: f64) -> SubPath { + SubPath::new( + vec![ + Anchor::corner(pt(0.0, -50.0)), + Anchor::corner(pt(100.0, -50.0)), + Anchor::with_handles(pt(100.0, 0.0), pt(100.0, 0.0), pt(70.0, bulge)), + Anchor::with_handles(pt(0.0, 0.0), pt(30.0, bulge), pt(0.0, 0.0)), + ], + true, + ) +} + +/// A closed shape below y = 0 whose upper edge runs left to right, curved up by `bulge`. +fn below(bulge: f64) -> SubPath { + SubPath::new( + vec![ + Anchor::with_handles(pt(0.0, 0.0), pt(0.0, 0.0), pt(30.0, -bulge)), + Anchor::with_handles(pt(100.0, 0.0), pt(70.0, -bulge), pt(100.0, 0.0)), + Anchor::corner(pt(100.0, 50.0)), + Anchor::corner(pt(0.0, 50.0)), + ], + true, + ) +} + +fn single(sp: SubPath) -> PathData { + PathData::single(sp) +} + +/// The plan at `at` with a 20 pt brush. +fn plan(a: &PathData, b: &PathData, at: Point) -> MergePlan { + stretches_at(a, b, at, 10.0, COINCIDENCE).expect("a stretch") +} + +/// The outline of `chain` run one way, sampled. +fn samples(chain: &[CubicBez]) -> Vec { + let n = chain.len() * 16; + let at = |k: usize| { + let f = k as f64 / n as f64 * chain.len() as f64; + let i = (f.floor() as usize).min(chain.len() - 1); + chain[i].eval(f - i as f64) + }; + (0..=n).map(at).collect() +} + +/// The edge as `path` now has it between anchors `from` (inclusive) and `to`, along the path. +fn edge(path: &PathData, from: usize, segments: usize) -> Vec { + let sp = &path.subpaths[0]; + (0..segments).map(|k| sp.segment((from + k) % sp.segment_count())).collect() +} + +/// The farthest apart two chains are, sampled alike. +fn apart(x: &[CubicBez], y: &[CubicBez]) -> f64 { + samples(x).iter().zip(samples(y)).map(|(p, q)| p.distance(q)).fold(0.0, f64::max) +} + +#[test] +fn the_merged_edge_lies_half_way_and_both_paths_keep_their_topology() { + let (a, b) = (single(above(6.0)), single(below(2.0))); + let plan = plan(&a, &b, pt(50.0, 0.0)); + assert_eq!( + plan, + MergePlan::Edge { + a: Stretch { subpath: 0, start: 2, segments: 1, reversed: false }, + b: Stretch { subpath: 0, start: 0, segments: 1, reversed: true }, + } + ); + let (na, nb) = apply_merge(&a, &b, &plan, Follow::Average).unwrap(); + assert!(na.subpaths[0].closed && nb.subpaths[0].closed); + assert_eq!((na.anchor_count(), nb.anchor_count()), (4, 4)); + // Everything else is where it was. + assert_eq!((na.subpaths[0].anchors[0], na.subpaths[0].anchors[1]), (a.subpaths[0].anchors[0], a.subpaths[0].anchors[1])); + assert_eq!((nb.subpaths[0].anchors[2], nb.subpaths[0].anchors[3]), (b.subpaths[0].anchors[2], b.subpaths[0].anchors[3])); + // One edge, run opposite ways by the two paths, between the two it replaced. + let (ea, eb) = (edge(&na, 2, 1), edge(&nb, 0, 1)); + assert!(apart(&ea, &backward(&eb)) < 1e-9); + let (upper, lower) = (a.subpaths[0].segment(2), b.subpaths[0].segment(0)); + for k in 0..=16 { + let t = k as f64 / 16.0; + let (p, u, l) = (ea[0].eval(t), upper.eval(t), lower.eval(1.0 - t)); + assert!(p.distance(u.midpoint(l)) < 1e-9, "{p:?} is half way between {u:?} and {l:?}"); + assert!(l.y - 1e-9 <= p.y && p.y <= u.y + 1e-9); + } + // Merged: there is nothing left to do, and it says so. + let again = stretches_at(&na, &nb, pt(50.0, 0.0), 10.0, COINCIDENCE).unwrap(); + assert!(matches!(merge_preview(&na, &nb, &again, Follow::Average), Some(MergePreview::Edge { done: true, .. }))); + let (ma, mb) = apply_merge(&na, &nb, &again, Follow::Average).unwrap(); + assert!(apart(&edge(&ma, 2, 1), &edge(&na, 2, 1)) < 1e-9 && apart(&edge(&mb, 0, 1), &edge(&nb, 0, 1)) < 1e-9); +} + +#[test] +fn following_a_path_keeps_it_and_the_other_takes_its_edge() { + let (a, b) = (single(above(6.0)), single(below(2.0))); + let plan = plan(&a, &b, pt(50.0, 0.0)); + let (na, nb) = apply_merge(&a, &b, &plan, Follow::First).unwrap(); + assert_eq!(na, a); + assert!(apart(&edge(&nb, 0, 1), &backward(&edge(&a, 2, 1))) < 1e-9); + let (na, nb) = apply_merge(&a, &b, &plan, Follow::Second).unwrap(); + assert_eq!(nb, b); + assert!(apart(&edge(&na, 2, 1), &backward(&edge(&b, 0, 1))) < 1e-9); +} + +#[test] +fn stretches_of_different_anchor_counts_get_the_same_anchors() { + let (with_two, _) = insert_at(&above(6.0), &[(2, 0.3)]); + assert_eq!(with_two.anchors.len(), 5); + let (a, b) = (single(with_two), single(below(2.0))); + let plan = plan(&a, &b, pt(50.0, 0.0)); + let (na, nb) = apply_merge(&a, &b, &plan, Follow::Average).unwrap(); + assert_eq!((na.anchor_count(), nb.anchor_count()), (5, 5)); + let (ea, eb) = (edge(&na, 2, 2), edge(&nb, 0, 2)); + assert!(apart(&ea, &backward(&eb)) < 1e-9); + assert_eq!(ea[0].p3, eb[0].p3); + // The anchors were smooth on the one and a point of a curve on the other: the joint stays smooth. + assert_eq!(na.subpaths[0].anchors[3].kind, AnchorKind::Smooth); + // Anchors at nearly the same place stand for one. + let (b2, _) = insert_at(&below(2.0), &[(0, 0.7)]); + let (a2, b2) = (a.clone(), single(b2)); + let (na, nb) = apply_merge(&a2, &b2, &stretches_at(&a2, &b2, pt(50.0, 0.0), 10.0, COINCIDENCE).unwrap(), Follow::Average).unwrap(); + assert_eq!((na.anchor_count(), nb.anchor_count()), (5, 5), "one anchor inside, not two"); +} + +#[test] +fn open_paths_stay_open_and_keep_what_lies_beyond_the_stretch() { + let a = single(SubPath::polyline(&[pt(-50.0, 0.0), pt(0.0, 0.0), pt(50.0, 4.0), pt(100.0, 0.0), pt(150.0, 40.0)], false)); + let b = single(SubPath::polyline(&[pt(100.0, 0.0), pt(50.0, -4.0), pt(0.0, 0.0), pt(-20.0, 60.0)], false)); + let plan = plan(&a, &b, pt(25.0, 0.0)); + assert_eq!( + plan, + MergePlan::Edge { + a: Stretch { subpath: 0, start: 1, segments: 2, reversed: false }, + b: Stretch { subpath: 0, start: 0, segments: 2, reversed: true }, + } + ); + let (na, nb) = apply_merge(&a, &b, &plan, Follow::Average).unwrap(); + assert!(!na.subpaths[0].closed && !nb.subpaths[0].closed); + let (pa, pb) = (&na.subpaths[0].anchors, &nb.subpaths[0].anchors); + assert_eq!(pa.iter().map(|x| x.p).collect::>(), [pt(-50.0, 0.0), pt(0.0, 0.0), pt(50.0, 0.0), pt(100.0, 0.0), pt(150.0, 40.0)]); + assert_eq!(pb.iter().map(|x| x.p).collect::>(), [pt(100.0, 0.0), pt(50.0, 0.0), pt(0.0, 0.0), pt(-20.0, 60.0)]); +} + +#[test] +fn a_stretch_over_the_start_of_a_closed_path_stays_closed_and_whole() { + // The edge along y = 0 runs from anchor 3 over anchor 0, which has no partner, to anchor 1. + let a = single(SubPath::polyline(&[pt(50.0, 1.0), pt(100.0, 0.0), pt(100.0, -50.0), pt(0.0, 0.0)], true)); + let b = single(SubPath::polyline(&[pt(100.0, 0.0), pt(0.0, 0.0), pt(0.0, 50.0), pt(100.0, 50.0)], true)); + let plan = plan(&a, &b, pt(25.0, 0.5)); + let MergePlan::Edge { a: sa, .. } = plan else { panic!("{plan:?}") }; + assert_eq!((sa.start, sa.segments), (3, 2)); + let (na, nb) = apply_merge(&a, &b, &plan, Follow::Average).unwrap(); + assert!(na.subpaths[0].closed && nb.subpaths[0].closed); + assert_eq!((na.anchor_count(), nb.anchor_count()), (4, 5)); + // Every anchor of the new outlines is where one was, or on the merged edge at y 0.5. + let at = |path: &PathData, p: Point| path.anchors().any(|(_, _, x)| x.p.distance(p) < 1e-4); + assert!(at(&na, pt(100.0, -50.0)) && at(&nb, pt(0.0, 50.0)) && at(&nb, pt(100.0, 50.0))); + assert!(at(&na, pt(50.0, 0.5)) && at(&nb, pt(50.0, 0.5)), "{na:?}"); +} + +#[test] +fn the_stretch_under_the_brush_is_the_one_along_the_shared_edge_not_the_long_way_round() { + let square = |x: f64| single(SubPath::polyline(&[pt(x, 0.0), pt(x + 100.0, 0.0), pt(x + 100.0, 100.0), pt(x, 100.0)], true)); + let (a, b) = (square(0.0), square(100.0)); + // Above the corner, each path's top edge is nearest: their long ways round face each other. + let MergePlan::Edge { a: sa, b: sb } = plan(&a, &b, pt(99.0, -2.0)) else { panic!() }; + assert_eq!((sa.start, sa.segments), (1, 1)); + assert_eq!((sb.start, sb.segments), (3, 1)); +} + +#[test] +fn stretches_too_unlike_or_without_two_coincident_ends_are_left_alone() { + let (a, b) = (single(above(6.0)), single(below(2.0))); + assert_eq!(stretches_at(&a, &b, pt(50.0, 40.0), 10.0, COINCIDENCE), None, "the brush doesn't touch both"); + // One coincident anchor only. + let c = single(SubPath::polyline(&[pt(0.0, 0.0), pt(101.0, 0.0)], false)); + assert_eq!(stretches_at(&a, &c, pt(50.0, 0.0), 10.0, COINCIDENCE), None); + // Coincident within a wider tolerance. + assert!(stretches_at(&a, &c, pt(50.0, 2.0), 10.0, 1.5).is_some()); + // One side twice as long as the other, both ends coincident. + let hump = single(SubPath::polyline(&[pt(0.0, 0.0), pt(50.0, 5.0), pt(50.0, 60.0), pt(100.0, 0.0)], false)); + let flat = single(SubPath::polyline(&[pt(0.0, 0.0), pt(100.0, 0.0)], false)); + assert_eq!(stretches_at(&hump, &flat, pt(25.0, 1.0), 10.0, COINCIDENCE), None); + // Past the limits, or not finite. + let mut bad = a.clone(); + bad.subpaths[0].anchors[0].p.x = f64::NAN; + assert_eq!(stretches_at(&bad, &b, pt(50.0, 0.0), 10.0, COINCIDENCE), None); + assert_eq!(ends_at(&bad, &b, pt(50.0, 0.0), 10.0), None); + assert_eq!(stretches_at(&a, &b, pt(f64::NAN, 0.0), 10.0, COINCIDENCE), None); + assert_eq!(stretches_at(&PathData::default(), &b, pt(50.0, 0.0), 10.0, COINCIDENCE), None); + assert_eq!(ends_at(&PathData::default(), &PathData::default(), pt(50.0, 0.0), 10.0), None); +} + +#[test] +fn near_anchors_meet_half_way_or_on_the_path_followed() { + let a = single(SubPath::polyline(&[pt(0.0, 0.0), pt(100.0, 0.0)], false)); + let b = single(SubPath::polyline(&[pt(104.0, 2.0), pt(200.0, 0.0)], false)); + let plan = ends_at(&a, &b, pt(102.0, 1.0), 10.0).unwrap(); + let ends = |f| { + let (na, nb) = apply_merge(&a, &b, &plan, f).unwrap(); + (na.subpaths[0].anchors[1].p, nb.subpaths[0].anchors[0].p) + }; + assert_eq!(ends(Follow::Average), (pt(102.0, 1.0), pt(102.0, 1.0))); + assert_eq!(ends(Follow::First), (pt(100.0, 0.0), pt(100.0, 0.0))); + assert_eq!(ends(Follow::Second), (pt(104.0, 2.0), pt(104.0, 2.0))); + assert_eq!(merge_preview(&a, &b, &plan, Follow::Average), Some(MergePreview::Ends { a: pt(100.0, 0.0), b: pt(104.0, 2.0), to: pt(102.0, 1.0) })); + // Together already: nothing to do. + let c = single(SubPath::polyline(&[pt(100.0, 0.0), pt(100.0, 90.0)], false)); + assert_eq!(ends_at(&a, &c, pt(100.0, 1.0), 10.0), None); + // Nothing there. + assert_eq!(ends_at(&a, &b, pt(50.0, 1.0), 10.0), None); +} + +#[test] +fn an_anchor_one_path_lacks_is_added_to_match() { + let a = single(SubPath::polyline(&[pt(0.0, 0.0), pt(100.0, 2.0), pt(100.0, 90.0)], false)); + let b = single(SubPath::polyline(&[pt(0.0, 0.0), pt(200.0, 0.0)], false)); + let plan = ends_at(&a, &b, pt(100.0, 1.0), 10.0).unwrap(); + assert!(matches!(plan, MergePlan::Ends { a: EndSide::Anchor { anchor: 1, .. }, b: EndSide::Insert { .. } }), "{plan:?}"); + let (na, nb) = apply_merge(&a, &b, &plan, Follow::Average).unwrap(); + assert_eq!(na, a, "the anchor stays"); + assert_eq!(nb.anchor_count(), 3); + assert!(nb.subpaths[0].anchors[1].p.distance(pt(100.0, 2.0)) < 1e-9); + // Following the path that lacks it, the anchor comes to it. + let (na, nb) = apply_merge(&a, &b, &plan, Follow::Second).unwrap(); + assert!(na.subpaths[0].anchors[1].p.distance(pt(100.0, 0.0)) < 1e-6); + assert!(nb.subpaths[0].anchors[1].p.distance(na.subpaths[0].anchors[1].p) < 1e-9); + // The same the other way round. + let swapped = ends_at(&b, &a, pt(100.0, 1.0), 10.0).unwrap(); + let (nb, na) = apply_merge(&b, &a, &swapped, Follow::Average).unwrap(); + assert_eq!((na.anchor_count(), nb.anchor_count()), (3, 3)); + // With both paths' ends made, the stretch between can merge. + let c = single(SubPath::polyline(&[pt(100.0, 2.0), pt(100.0, 90.0)], false)); + let d = single(SubPath::polyline(&[pt(95.0, 2.0), pt(95.0, 90.0)], false)); + assert_eq!(stretches_at(&c, &d, pt(97.0, 50.0), 10.0, COINCIDENCE), None); + let first = ends_at(&c, &d, pt(97.0, 3.0), 10.0).unwrap(); + let (c, d) = apply_merge(&c, &d, &first, Follow::Average).unwrap(); + let second = ends_at(&c, &d, pt(97.0, 89.0), 10.0).unwrap(); + let (c, d) = apply_merge(&c, &d, &second, Follow::Average).unwrap(); + assert!(stretches_at(&c, &d, pt(97.5, 50.0), 10.0, COINCIDENCE).is_some()); +} + +#[test] +fn a_smooth_end_whose_handles_part_turns_into_a_corner() { + let a = single(SubPath::new( + vec![Anchor::smooth(pt(0.0, 0.0), pt(30.0, 10.0)), Anchor::with_handles(pt(100.0, 0.0), pt(70.0, 10.0), pt(100.0, 0.0))], + true, + )); + let b = single(SubPath::polyline(&[pt(0.0, 0.0), pt(100.0, 0.0)], false)); + let plan = plan(&a, &b, pt(50.0, 3.0)); + let (na, _) = apply_merge(&a, &b, &plan, Follow::Second).unwrap(); + assert_eq!(na.subpaths[0].anchors[0].kind, AnchorKind::Corner); +} + +#[test] +fn follow_names_round_trip() { + for f in Follow::ALL { + assert_eq!(Follow::parse(f.id()), Some(f)); + } + assert_eq!(Follow::parse("up"), None); +} diff --git a/crates/pathops/src/addons/pathtools/slide.rs b/crates/pathops/src/addons/pathtools/slide.rs new file mode 100644 index 000000000..30e4fdbbb --- /dev/null +++ b/crates/pathops/src/addons/pathtools/slide.rs @@ -0,0 +1,107 @@ +//! Sliding an anchor along its path: the anchor moves to a point on one of its two segments and +//! the segments beside it are refitted, so the outline stays where it was. It is a new anchor at +//! that point (an exact split) with the old one removed by the shared keep-shape refit. + +use kurbo::{CubicBez, ParamCurve, ParamCurveDeriv, ParamCurveNearest, Point}; +use vectorcraft_geom::SubPath; + +use super::{ACCURACY, TINY, insert_at, neighbours}; +use crate::addons::keep_shape::remove_anchor_keep_shape; +use crate::fit::is_straight; + +/// How far along a neighbouring segment an anchor can slide: stopping short of the next anchor +/// keeps a segment between them. +pub const MAX_SLIDE: f64 = 0.98; + +/// Steps of the search for the places on a curve where the tangent is at a snapping angle. +const STOP_SAMPLES: usize = 64; +/// Bisection steps that pin one of those places down. +const STOP_STEPS: usize = 40; +/// A stop this close to the anchor is the anchor itself. +const MIN_STOP: f64 = 1e-6; + +/// The indices of the anchors beside anchor `i`; `None` for an end of an open subpath. +fn both_neighbours(sp: &SubPath, i: usize) -> Option<(usize, usize)> { + if i >= sp.anchors.len() { + return None; + } + match neighbours(sp, i) { + (Some(prev), Some(next)) => Some((prev, next)), + _ => None, + } +} + +/// Anchor `index` of `sp` moved along the path by `t` → (the new subpath, how far its outline +/// strays from the old one, in points). `t` says where: a positive one is the curve parameter on +/// the segment after the anchor, a negative one the parameter back from the end of the segment +/// before it (`-t` from 1). The subpath keeps its anchor count and the anchor its index. `None` when the anchor can't slide: an end of +/// an open subpath, out of range, `t` not a number or past [`MAX_SLIDE`], non-finite coordinates, +/// or a closed subpath of two anchors. +pub fn slide_anchor(sp: &SubPath, index: usize, t: f64) -> Option<(SubPath, f64)> { + if t.is_nan() || t.abs() > MAX_SLIDE { + return None; + } + let (prev, _) = both_neighbours(sp, index)?; + let forward = t > 0.0; + let (split, _) = if forward { insert_at(sp, &[(index, t)]) } else { insert_at(sp, &[(prev, 1.0 + t)]) }; + // Too small a move to split a segment: the anchor stays. + if split.anchors.len() == sp.anchors.len() { + return Some((sp.clone(), 0.0)); + } + // Backward the new anchor comes just before the old one, or last on a closed subpath's first. + let old = if !forward && index > 0 { index + 1 } else { index }; + let (mut out, strayed) = remove_anchor_keep_shape(&split, old)?; + if !forward && index == 0 { + out.anchors.rotate_right(1); + } + Some((out, strayed)) +} + +/// The slide (see [`slide_anchor`]) that brings anchor `index` nearest to `p`, held to +/// [`MAX_SLIDE`]; `None` when the anchor can't slide. +pub fn slide_t_at(sp: &SubPath, index: usize, p: Point) -> Option { + let (prev, _) = both_neighbours(sp, index)?; + let (before, after) = (sp.segment(prev).nearest(p, ACCURACY), sp.segment(index).nearest(p, ACCURACY)); + let t = if before.distance_sq < after.distance_sq { before.t - 1.0 } else { after.t }; + Some(t.clamp(-MAX_SLIDE, MAX_SLIDE)) +} + +/// The slides of anchor `index` (see [`slide_anchor`]) that land where the path's tangent is at a +/// multiple of 45° from `base` degrees, in order. +pub fn slide_stops(sp: &SubPath, index: usize, base: f64) -> Vec { + let Some((prev, _)) = both_neighbours(sp, index) else { return vec![] }; + let before = tangent_stops(&sp.segment(prev), base).into_iter().map(|t| t - 1.0); + before.chain(tangent_stops(&sp.segment(index), base)).filter(|t| (MIN_STOP..=MAX_SLIDE).contains(&t.abs())).collect() +} + +/// The parameters of `c` inside 0..1 where its tangent is at a multiple of 45° from `base` +/// degrees, in order. A straight segment has none: its tangent never reaches one or never leaves. +fn tangent_stops(c: &CubicBez, base: f64) -> Vec { + if is_straight(c, TINY) { + return vec![]; + } + let d = c.deriv(); + let base = base.to_radians(); + // Zero at each multiple, and it doesn't jump where the angle wraps round or the curve reverses. + let f = |t: f64| { + let v = d.eval(t).to_vec2(); + (v.hypot() > TINY).then(|| ((v.y.atan2(v.x) - base) * 4.0).sin()) + }; + let mut out = vec![]; + for k in 0..STOP_SAMPLES { + let (mut a, mut b) = (k as f64 / STOP_SAMPLES as f64, (k + 1) as f64 / STOP_SAMPLES as f64); + let (Some(fa), Some(fb)) = (f(a), f(b)) else { continue }; + if (fa < 0.0) == (fb < 0.0) { + continue; + } + for _ in 0..STOP_STEPS { + let m = (a + b) / 2.0; + match f(m) { + Some(fm) if (fm < 0.0) == (fa < 0.0) => a = m, + _ => b = m, + } + } + out.push(b); + } + out +} diff --git a/crates/pathops/src/addons/pathtools/tests.rs b/crates/pathops/src/addons/pathtools/tests.rs index dc2203a0e..121fe6653 100644 --- a/crates/pathops/src/addons/pathtools/tests.rs +++ b/crates/pathops/src/addons/pathtools/tests.rs @@ -294,3 +294,143 @@ fn the_brush_kernel_removes_only_marked_anchors() { assert_eq!(simplify_anchors_keep_shape(&bump, 0.1, &[true; 5]).anchors.len(), 4, "only the anchor on the straight run goes"); assert_eq!(simplify_anchors_keep_shape(&bump, f64::INFINITY, &[true; 5]).anchors.len(), 2, "unless tolerance is ignored"); } + +/// A circle about (0, 0) of radius 100 with `n` smooth anchors, each arc a cubic. +fn circle(n: usize) -> SubPath { + let step = std::f64::consts::TAU / n as f64; + let k = 4.0 / 3.0 * (step / 4.0).tan() * 100.0; + let anchors = (0..n).map(|i| { + let a = i as f64 * step; + let (p, tan) = (pt(a.cos() * 100.0, a.sin() * 100.0), Vec2::new(-a.sin(), a.cos())); + Anchor { p, h_in: p - tan * k, h_out: p + tan * k, kind: AnchorKind::Smooth } + }); + SubPath::new(anchors.collect(), true) +} + +#[test] +fn a_slid_anchor_keeps_the_shape_its_count_and_its_index() { + let sp = circle(8); + for (index, t) in [(2, 0.5), (2, -0.5), (0, -0.25), (0, 0.25), (7, 0.9), (7, -0.9)] { + let (out, strayed) = slide_anchor(&sp, index, t).unwrap(); + let seg = if t > 0.0 { sp.segment(index) } else { sp.segment((index + 7) % 8) }; + let want = seg.eval(if t > 0.0 { t } else { 1.0 + t }); + assert_eq!(out.anchors.len(), 8, "{index} {t}"); + assert!(out.anchors[index].p.distance(want) < 1e-9, "{index} {t}: the anchor is where it was sent"); + assert!(strayed < 0.05 && off_circle(&out, pt(0.0, 0.0), 100.0) < 0.05, "{index} {t}: {strayed}"); + // The other anchors stay. + assert!((0..8).filter(|i| *i != index).all(|i| out.anchors[i].p == sp.anchors[i].p), "{index} {t}"); + } +} + +#[test] +fn sliding_along_straight_runs_stays_straight_and_ends_do_not_slide() { + let sp = line(&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0), (200.0, 100.0)], false); + let (out, strayed) = slide_anchor(&sp, 1, 0.5).unwrap(); + assert_eq!(out.anchors[1].p, pt(150.0, 0.0)); + assert!(strayed < 1e-9 && out.segment_is_line(0) && out.segment_is_line(1)); + assert_eq!(slide_anchor(&sp, 1, 0.0).unwrap().0, sp, "no move"); + assert_eq!(slide_anchor(&sp, 1, 1e-9).unwrap().0, sp, "too small a move"); + for (i, t) in [(0, 0.5), (3, -0.5), (9, 0.5), (1, 0.99), (1, -1.0), (1, f64::NAN), (1, f64::INFINITY)] { + assert!(slide_anchor(&sp, i, t).is_none(), "{i} {t}"); + } + let mut bad = sp.clone(); + bad.anchors[1].h_out = pt(f64::NAN, 0.0); + assert!(slide_anchor(&bad, 1, 0.5).is_none()); +} + +#[test] +fn the_slide_a_pointer_asks_for_and_where_the_tangent_stops() { + let sp = line(&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)], false); + assert!(near(slide_t_at(&sp, 1, pt(150.0, 30.0)).unwrap(), 0.5, 1e-6)); + let back = slide_t_at(&sp, 1, pt(75.0, -30.0)).unwrap(); + assert!(back < 0.0 && sp.segment(0).eval(1.0 + back).distance(pt(75.0, 0.0)) < 1e-3, "{back}"); + assert!(near(slide_t_at(&sp, 1, pt(900.0, 0.0)).unwrap(), MAX_SLIDE, 1e-9), "held short of the next anchor"); + assert!(slide_t_at(&sp, 0, pt(50.0, 0.0)).is_none()); + assert!(slide_stops(&sp, 1, 0.0).is_empty(), "a straight run has no tangent to reach"); + let slanted = line(&[(0.0, 0.0), (100.0, 100.0), (250.0, 250.0)], false); + assert!(slide_stops(&slanted, 1, 0.0).is_empty(), "nor one that lies on a multiple of 45°"); + // Round a circle of 4 anchors the tangent is at a multiple of 45° at each anchor and half way. + let round = circle(4); + let stops = slide_stops(&round, 0, 0.0); + assert_eq!(stops.len(), 2, "{stops:?}"); + assert!(near(stops[0], -0.5, 1e-6) && near(stops[1], 0.5, 1e-6), "{stops:?}"); + // From a constrain angle of 22.5°, between those. + let stops = slide_stops(&round, 0, 22.5); + assert_eq!(stops.len(), 4, "{stops:?}"); + assert!(stops.windows(2).all(|w| w[0] < w[1])); +} + +#[test] +fn add_anchors_spaces_them_by_count_or_distance() { + let sq = line(&[(0.0, 0.0), (90.0, 0.0), (90.0, 90.0), (0.0, 90.0)], true); + let out = add_anchors(&sq, SegmentKind::All, AnchorSpacing::Count(2), 100, 100).unwrap(); + assert_eq!(out.anchors.len(), 12); + assert!(near(out.anchors[1].p.x, 30.0, 1e-6) && near(out.anchors[2].p.x, 60.0, 1e-6)); + assert!(out.closed); + let out = add_anchors(&sq, SegmentKind::All, AnchorSpacing::Distance(40.0), 100, 100).unwrap(); + assert_eq!(out.anchors.len(), 12, "at 40 and 80, none past the segment"); + assert!(near(out.anchors[2].p.x, 80.0, 1e-6)); + assert_eq!(add_anchors(&sq, SegmentKind::All, AnchorSpacing::Distance(1.0), 3, 100).unwrap().anchors.len(), 16, "the cap"); + assert_eq!(add_anchors(&sq, SegmentKind::All, AnchorSpacing::Distance(0.0), 3, 100).unwrap(), sq); + assert_eq!(add_anchors(&sq, SegmentKind::Curved, AnchorSpacing::Count(1), 3, 100).unwrap(), sq, "no curves"); + assert_eq!(add_anchors(&sq, SegmentKind::All, AnchorSpacing::Count(3), 9, 12).unwrap().anchors.len(), 16, "room for all of them"); + assert!(add_anchors(&sq, SegmentKind::All, AnchorSpacing::Count(3), 9, 11).is_none(), "no room"); + // A curve keeps its shape, and a zero-length segment gets nothing. + let bent = SubPath::new( + vec![ + Anchor { h_out: pt(0.0, 50.0), ..Anchor::corner(pt(0.0, 0.0)) }, + Anchor { h_in: pt(100.0, 50.0), ..Anchor::corner(pt(100.0, 0.0)) }, + Anchor::corner(pt(100.0, 0.0)), + ], + false, + ); + let out = add_anchors(&bent, SegmentKind::Curved, AnchorSpacing::Count(3), 9, 9).unwrap(); + assert_eq!(out.anchors.len(), 6); + assert!(near(length(&out), length(&bent), 1e-4)); + assert_eq!(add_anchors(&bent, SegmentKind::Straight, AnchorSpacing::Count(3), 9, 9).unwrap(), bent); +} + +#[test] +fn smoothing_every_anchor_reads_the_same_either_way_along_the_path() { + let zig = line(&[(0.0, 0.0), (30.0, 40.0), (70.0, 0.0), (100.0, 50.0)], false); + let mut there = zig.clone(); + smooth_anchors(&mut there, SMOOTH_RATIO); + let mut back = zig.clone(); + back.reverse(); + smooth_anchors(&mut back, SMOOTH_RATIO); + back.reverse(); + for (a, b) in there.anchors.iter().zip(&back.anchors) { + assert!(a.h_in.distance(b.h_in) < 1e-9 && a.h_out.distance(b.h_out) < 1e-9, "{a:?} {b:?}"); + } + assert!(there.anchors[1].kind == AnchorKind::Smooth && there.anchors[0].has_out() && there.anchors[3].has_in()); + // Nothing to smooth. + for mut sp in [SubPath::default(), line(&[(1.0, 1.0)], false), line(&[(1.0, 1.0)], true)] { + let before = sp.clone(); + smooth_anchors(&mut sp, SMOOTH_RATIO); + assert_eq!(sp, before); + } +} + +#[test] +fn splitting_reads_an_anchors_kind_from_its_handles() { + // Smooth by its handles though it is called a corner, as a path read back from an outline is. + let run = SubPath::new( + vec![ + Anchor::corner(pt(0.0, 0.0)), + Anchor { h_in: pt(40.0, 0.0), h_out: pt(60.0, 0.0), ..Anchor::corner(pt(50.0, 0.0)) }, + Anchor::corner(pt(100.0, 0.0)), + Anchor::corner(pt(100.0, 50.0)), + ], + false, + ); + assert_eq!(split_at_anchors(&run, false, true).len(), 2); + assert_eq!(split_at_anchors(&run, true, false).len(), 2); + assert_eq!(split_at_anchors(&run, true, true).len(), 3); + assert_eq!(split_at_anchors(&run, false, false), std::slice::from_ref(&run)); + let pieces = split_at_anchors(&run, true, true); + assert!(near(pieces.iter().map(length).sum::(), length(&run), 1e-6), "the shape stays"); + // A closed subpath opens at its first cut. + let sq = line(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)], true); + assert_eq!(split_at_anchors(&sq, true, false).len(), 3); + assert!(split_at_anchors(&sq, true, false).iter().all(|p| !p.closed)); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index d94ade86b..529396fce 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -55,6 +55,8 @@ pub(crate) struct Exporter<'a> { symbols: SymbolBoxes<'a>, warnings: Vec, images: HashMap>, + /// `Some` while opacity mask art is written: whether an image in it was left out. + image_left_out: Option, /// The brush library, parsed when the first brushed stroke is written. brushes: Option>, /// Whether the group being written is a knockout group (what its neutral children inherit). diff --git a/crates/pdf/src/export/exporter.rs b/crates/pdf/src/export/exporter.rs index c7d21793d..ac53090f5 100644 --- a/crates/pdf/src/export/exporter.rs +++ b/crates/pdf/src/export/exporter.rs @@ -14,6 +14,9 @@ pub(super) const MAX_PATTERN_DEPTH: u32 = 4; /// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`). pub(super) const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; +/// The warning for an image whose placement cannot be written. +pub(super) const UNWRITABLE_IMAGE: &str = "an image couldn't be written and is missing from the picture"; + impl<'a> Exporter<'a> { /// An exporter of `doc` writing images as `set` says; layers whose Print option is off are /// left out. @@ -23,6 +26,7 @@ impl<'a> Exporter<'a> { symbols: doc.symbol_boxes().quick().with_brush_art(), warnings: vec![], images: HashMap::new(), + image_left_out: None, brushes: None, knockout: doc.page_knockout, lab_spots: vec![], diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index 74fa5ba46..79169dc14 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -1,4 +1,4 @@ -use super::exporter::{UNDECODED, q, writable, xf}; +use super::exporter::{UNDECODED, UNWRITABLE_IMAGE, q, writable, xf}; use super::{Arc, BlendMode, Color, Exporter, Image, NodeKind, Paint, Rect, Size, Surface, cmyk, krilla}; use krilla::color::device_n::{Colorant, DeviceNSpace, Process}; use krilla::image::{BitsPerComponent, CustomImage, ImageColorspace}; @@ -57,10 +57,10 @@ impl Exporter<'_> { /// that ink (a DeviceN space of the one ink, which lists its Separation space), Registration in /// every process ink and every spot ink of the document, other colors in gray, RGB or CMYK as /// the Output settings write them. The coverage is resampled as the Compression settings ask - /// ([`crate::images::coverage_sized`]). `None` when the paint isn't a solid color or the pixels - /// can't be decoded or its color channel exceeds [`vectorcraft_doc::MAX_RASTER_ALLOC`]; cached - /// like [`Self::load_image`]. With `interpolate` (and the export's own setting), viewers may - /// smooth it. + /// ([`crate::images::coverage_sized`]). `None` when the paint isn't a solid color, the image is + /// missing or left out, its pixels can't be decoded, or its color channel exceeds + /// [`vectorcraft_doc::MAX_RASTER_ALLOC`]; image rejections are cached like [`Self::load_image`]. + /// With `interpolate` (and the export's own setting), viewers may smooth it. pub(super) fn colorized_image(&mut self, im: &vectorcraft_doc::ImageObject, paint: &Paint, interpolate: bool) -> Option { let Paint::Solid { color, swatch, tint } = paint else { return None }; let size = placed_size(im); @@ -73,11 +73,15 @@ impl Exporter<'_> { return i.clone(); } let doc = self.doc; - let (width, height, alpha) = doc + let Some((width, height, alpha)) = doc .image_blob(im) .filter(|_| !self.left_out.contains(&im.key)) .and_then(|b| b.coverage()) - .and_then(|(w, h, cover)| crate::images::coverage_sized(w, h, cover, size, self.compression))?; + .and_then(|(w, h, cover)| crate::images::coverage_sized(w, h, cover, size, self.compression)) + else { + self.images.insert(cache, None); + return None; + }; let n = alpha.len(); let registration = swatch.as_deref() == Some(vectorcraft_color::swatch::REGISTRATION); let inks: Vec<(String, [f32; 4])> = if registration { @@ -103,7 +107,10 @@ impl Exporter<'_> { other => (other.to_rgb().map(q).to_vec(), ImageColorspace::Rgb), }, }; - tinted_color_len(samples.len(), n)?; + if tinted_color_len(samples.len(), n).is_none() { + self.images.insert(cache, None); + return None; + } let color = Arc::new(samples.repeat(n)); let img = Image::from_custom(Tinted { color, alpha: Arc::new(alpha), width, height, space, device_n }, interpolate).ok(); self.images.insert(cache, img.clone()); @@ -190,10 +197,23 @@ impl Exporter<'_> { pub(super) fn image(&mut self, s: &mut Surface, im: &vectorcraft_doc::ImageObject, img: Option, pixelated: bool) { // An image placed or sized beyond [`MAX_COORD`] is left out. if !writable(s, im.xf, Rect::new(0.0, 0.0, im.width.max(1).into(), im.height.max(1).into())) { + if self.image_left_out.is_some() { + self.image_left_out = Some(true); + self.warn(UNWRITABLE_IMAGE); + } return; } - let Some(img) = img.or_else(|| self.load_image(im, placed_size(im), !pixelated)) else { return }; - let Some(size) = Size::from_wh(im.width.max(1) as f32, im.height.max(1) as f32) else { return }; + let Some(img) = img.or_else(|| self.load_image(im, placed_size(im), !pixelated)) else { + self.image_left_out = self.image_left_out.map(|_| true); + return; + }; + let Some(size) = Size::from_wh(im.width.max(1) as f32, im.height.max(1) as f32) else { + if self.image_left_out.is_some() { + self.image_left_out = Some(true); + self.warn(UNWRITABLE_IMAGE); + } + return; + }; s.push_transform(&xf(im.xf)); s.draw_image(img, size); s.pop(); @@ -256,7 +276,31 @@ pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixtee #[cfg(test)] mod tests { - use super::tinted_color_len; + use super::{Exporter, tinted_color_len}; + use vectorcraft_color::{Color, Paint}; + use vectorcraft_doc::{Document, ImageBlob, ImageObject}; + use vectorcraft_geom::Affine; + + #[test] + fn colorized_image_caches_rejected_images() { + let mut doc = Document::new(10.0, 10.0); + doc.images.insert("bad".into(), ImageBlob::png(vec![1, 2, 3])); + doc.images.insert("left-out".into(), ImageBlob::png(vec![1, 2, 3])); + let settings = crate::PdfSettings::default(); + let mut exporter = Exporter::new(&doc, &settings); + exporter.left_out.insert("left-out".into()); + let paint = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + + for key in ["missing", "bad", "left-out"] { + let image = + ImageObject { key: key.into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + assert!(exporter.colorized_image(&image, &paint, true).is_none(), "{key}"); + let cache = format!("{key}\u{0}{paint:?}\u{0}1.000x1.000"); + assert!(matches!(exporter.images.get(&cache), Some(None)), "rejection for {key} was not cached"); + assert!(exporter.colorized_image(&image, &paint, true).is_none(), "second call for {key}"); + } + assert_eq!(exporter.images.len(), 3); + } #[test] fn tinted_color_rejects_oversized_and_overflowing_buffers() { diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index f2edf9468..9740362ec 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -7,7 +7,7 @@ use super::{ impl Exporter<'_> { /// Opacity mask → luminosity soft mask. Outside the art the backdrop is black (clip) or white; /// invert is a white Difference rect on top (luminance is linear, so luma(1 − c) = 1 − luma(c)). - fn soft_mask(&mut self, s: &mut Surface, m: &vectorcraft_doc::OpacityMask, page: Rect) -> krilla::mask::Mask { + fn soft_mask(&mut self, s: &mut Surface, m: &vectorcraft_doc::OpacityMask, page: Rect) -> Option { let mut sb = s.stream_builder(); let mut ms = sb.surface(); // An opaque backdrop when it matters: white outside the art (no clip), or black for the @@ -19,15 +19,21 @@ impl Exporter<'_> { // and has no inks to overprint. let knockout = std::mem::take(&mut self.knockout); let overprint = std::mem::take(&mut self.overprint); + let image_left_out = self.image_left_out.replace(false); self.node(&mut ms, &m.art, page, true); + let incomplete = self.image_left_out.unwrap_or(false); + self.image_left_out = image_left_out.map(|outer| outer || incomplete); (self.knockout, self.overprint) = (knockout, overprint); + if incomplete { + return None; + } if m.invert { ms.push_blend_mode(krilla::blend::BlendMode::Difference); cover(&mut ms, page, 255, 1.0); ms.pop(); } ms.finish(); - krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity) + Some(krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)) } /// The children of a group, as the elements of a knockout group when it is one. @@ -143,7 +149,13 @@ impl Exporter<'_> { if let Some(m) = mask && alpha.is_none() { - let mask = self.soft_mask(s, m, page); + let Some(mask) = self.soft_mask(s, m, page) else { + self.knockout = enclosing; + for _ in 0..pushes { + s.pop(); + } + return; + }; s.push_mask(mask); pushes += 1; } @@ -188,8 +200,12 @@ impl Exporter<'_> { if let Some(colorized) = self.colorized_image(im, p, !n.extras().pixelated) { self.image(s, im, Some(colorized), n.extras().pixelated); } else { + self.image_left_out = self.image_left_out.map(|_| true); self.warn(UNDECODED); } + } else if self.image_left_out.is_some() { + self.image_left_out = Some(true); + self.warn(super::exporter::UNWRITABLE_IMAGE); } } _ => self.image(s, im, alpha, n.extras().pixelated), diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 15a05c472..f0049787f 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -48,13 +48,11 @@ impl<'p> Builder<'p> { taken: vec![], scan: Scan::default(), tag_at: 0, - group_at: 0, paint_at: 0, aligned: true, route, unsure: false, marked: vec![], - pending: false, nested: 0, white_images: false, last_fill: None, @@ -103,8 +101,7 @@ impl<'p> Builder<'p> { self.mask = None; self.masked.clear(); self.marked.clear(); - self.pending = false; - (self.scan, self.tag_at, self.group_at, self.aligned, self.paint_at) = (scan, 0, 0, true, 0); + (self.scan, self.tag_at, self.aligned, self.paint_at) = (scan, 0, true, 0); if self.scan.overprints && self.scan.zero_mode { self.warn(OVERPRINT_ZERO_MODE); } @@ -478,14 +475,12 @@ impl<'p> Builder<'p> { blend: std::mem::replace(&mut self.blend, BlendMode::Normal), mask: self.mask.take(), page: std::mem::replace(&mut self.page, page), - pending: std::mem::take(&mut self.pending), }; self.nested += 1; read(self); let art = self.close_frames(); self.nested -= 1; - (self.stack, self.root_groups, self.blend, self.mask, self.page, self.pending) = - (saved.stack, saved.root_groups, saved.blend, saved.mask, saved.page, saved.pending); + (self.stack, self.root_groups, self.blend, self.mask, self.page) = (saved.stack, saved.root_groups, saved.blend, saved.mask, saved.page); Some(art) } diff --git a/crates/pdf/src/import/device.rs b/crates/pdf/src/import/device.rs index 3338124ad..f89deaaf9 100644 --- a/crates/pdf/src/import/device.rs +++ b/crates/pdf/src/import/device.rs @@ -35,11 +35,9 @@ impl<'a> Device<'a> for Builder<'_> { self.flush_glyphs(); let walked = self.walked(false); if self.hold_glyph_stroke(path, transform, paint, draw_mode, walked) { - self.pending = false; return; } self.flush_text(); - self.pending = false; let overprint = self.path_overprint(); let mut bp = path.clone(); bp.apply_affine(transform); @@ -140,17 +138,6 @@ impl<'a> Device<'a> for Builder<'_> { fn push_clip_path(&mut self, clip_path: &ClipPath) { self.flush(); - // A form's box right after its transparency group: the group's isolate and knockout - // flags are the next ones read from the file. - if std::mem::take(&mut self.pending) { - let flags = self.scan.groups.get(self.group_at).copied(); - self.group_at += 1; - if let Some((i, k)) = flags - && let Some(Frame { kind: FrameKind::Group { isolate, knockout, .. }, .. }) = self.stack.last_mut() - { - (*isolate, *knockout) = (i, k); - } - } // Clips that contain the whole page do nothing visible; don't create groups for them. Only // a polygon that really covers it counts: a diamond touching the page edges has the page // as its bounding box but cuts its corners away (#1542). @@ -165,7 +152,6 @@ impl<'a> Device<'a> for Builder<'_> { self.flush(); let mask = mask.map(|m| self.soft_mask(&m)); self.stack.push(Frame::new(FrameKind::Group { opacity, blend: blend(blend_mode), mask, isolate: false, knockout: false })); - self.pending = self.nested == 0; // Blend mode applies to the group as a whole, not to its children. self.blend = BlendMode::Normal; } @@ -182,7 +168,6 @@ impl<'a> Device<'a> for Builder<'_> { if matches!(draw_mode, GlyphDrawMode::Clip) { return; } - self.pending = false; let stroke = match draw_mode { // Invisible text (a scanned page's recognized text, over the scan) draws nothing but // is still text: type with neither fill nor stroke, so it stays searchable. @@ -241,7 +226,6 @@ impl<'a> Device<'a> for Builder<'_> { fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { self.flush_glyphs(); self.flush_text(); - self.pending = false; match image { Image::Raster(r) => { let white = self.white_images; @@ -328,13 +312,25 @@ impl<'a> Device<'a> for Builder<'_> { fn pop_clip_path(&mut self) { self.flush(); - self.pending = false; self.pop_frame(); } + // The interpreter tells the flags of a form's group right after pushing it. (It draws a group + // that adds nothing straight onto its parent, without pushing it.) + fn set_knockout(&mut self) { + if let Some(Frame { kind: FrameKind::Group { knockout, .. }, .. }) = self.stack.last_mut() { + *knockout = true; + } + } + + fn set_isolated(&mut self) { + if let Some(Frame { kind: FrameKind::Group { isolate, .. }, .. }) = self.stack.last_mut() { + *isolate = true; + } + } + fn pop_transparency_group(&mut self) { self.flush(); - self.pending = false; self.pop_frame(); } diff --git a/crates/pdf/src/import/state.rs b/crates/pdf/src/import/state.rs index 0f02e7a26..f2d974f15 100644 --- a/crates/pdf/src/import/state.rs +++ b/crates/pdf/src/import/state.rs @@ -69,7 +69,6 @@ pub(super) struct Saved { pub(super) blend: BlendMode, pub(super) mask: Option>, pub(super) page: Rect, - pub(super) pending: bool, } pub(super) struct Builder<'p> { @@ -129,7 +128,6 @@ pub(super) struct Builder<'p> { /// The page as walked by [`scan_page`], and how far the interpreter is through it. pub(super) scan: Scan, pub(super) tag_at: usize, - pub(super) group_at: usize, /// The fills and strokes of paths drawn so far ([`Scan::paints`]). pub(super) paint_at: usize, pub(super) aligned: bool, @@ -140,8 +138,6 @@ pub(super) struct Builder<'p> { pub(super) unsure: bool, /// The marked-content sequences open: the group each marks. pub(super) marked: Vec>, - /// A transparency group was just pushed: a form's (whose flags come next) or an image's. - pub(super) pending: bool, pub(super) nested: u32, /// Reading an alpha soft mask's art: images are read white with their own alpha, so the /// mask's luminance is their alpha, as whitened vector art's is (cairo, and so Inkscape, diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs index c75d012a2..dac1a7b64 100644 --- a/crates/pdf/src/import_scan.rs +++ b/crates/pdf/src/import_scan.rs @@ -1,17 +1,15 @@ //! What the interpreter doesn't tell the import, read from the file itself: the optional content //! groups (layers) with their states and nesting, which group each marked-content sequence belongs -//! to, the isolate and knockout flags of transparency groups, the overprint parameters each path -//! and each stretch of text is painted with, the Separation and DeviceN inks they are painted in -//! (by their colorant names, which the interpreter drops), and the names of the fonts. +//! to, the overprint parameters each path and each stretch of text is painted with, the Separation +//! and DeviceN inks they are painted in (by their colorant names, which the interpreter drops), and +//! the names of the fonts. //! -//! hayro-interpret reports a marked-content sequence by its tag alone and a transparency group -//! without its flags, and skips the content of groups that are off. So each page's content is -//! walked here in the interpreter's order (page content, then form XObjects as they are drawn, -//! skipping what it skips): the k-th `BDC`/`BMC` it reports is the k-th one found here (its tag -//! checks that), the k-th form transparency group is the k-th group found here, and the k-th -//! path it fills or strokes is the k-th one painted here (the page's count checks that). To import -//! content that is off as hidden layers, [`all_on`] gives the file an update that turns every -//! group on. +//! hayro-interpret reports a marked-content sequence by its tag alone and skips the content of +//! groups that are off. So each page's content is walked here in the interpreter's order (page +//! content, then form XObjects as they are drawn, skipping what it skips): the k-th `BDC`/`BMC` it +//! reports is the k-th one found here (its tag checks that), and the k-th path it fills or strokes +//! is the k-th one painted here (the page's count checks that). To import content that is off as +//! hidden layers, [`all_on`] gives the file an update that turns every group on. use std::collections::{HashMap, HashSet}; @@ -258,8 +256,6 @@ pub(crate) struct Scan { /// Each marked-content sequence: its tag ([`tag_key`]) and the group (index into /// [`Ocgs::list`]) it marks. pub tags: Vec<(u64, Option)>, - /// Each form transparency group: isolated, knockout. - pub groups: Vec<(bool, bool)>, /// Content was incomplete or a scan limit was reached. No document may be returned from /// this scan, since its routing no longer describes every object the interpreter draws. pub cut: bool, @@ -846,13 +842,6 @@ impl Walker<'_, '_> { self.hidden_form |= !self.is_visible(); } if self.is_visible() { - if let Some(g) = d.get::>(b"Group") { - if self.out.groups.len() >= MAX_GROUPS { - self.out.cut = true; - return; - } - self.out.groups.push((g.get::(b"I").unwrap_or(false), g.get::(b"K").unwrap_or(false))); - } // (Its own resources, or without any those of what draws it: as the interpreter reads them.) let inner = Resources::from_scope(d.get::>(b"Resources"), res.clone()); self.save(); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index e442c02d5..0b7e3acea 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -973,6 +973,77 @@ fn opacity_mask_exports_as_luminosity_soft_mask() { assert!(text.contains("/Luminosity")); } +/// The warnings and path count after exporting an image mask and a separate rectangle. +fn image_mask_report(colorized: bool, available: bool, nested: bool, unwritable: bool) -> (Vec, usize) { + let mut d = doc(100.0, 100.0); + if available { + d.images.insert("mask-image".into(), ImageBlob::png(tiny_png())); + } + let im = ImageObject { + key: "mask-image".into(), + width: 4, + height: 2, + xf: Affine::translate(if unwritable { (1e10, 1e10) } else { (10.0, 10.0) }) * Affine::scale(10.0), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut art = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); + if colorized { + art.extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)))); + } + if nested { + let mut outer_art = rect_node(Rect::new(10.0, 10.0, 50.0, 30.0), Color::WHITE); + outer_art.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, false))); + art = outer_art; + } + let mut masked = rect_node(Rect::new(10.0, 10.0, 50.0, 30.0), Color::rgb(1.0, 0.0, 0.0)); + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, false))); + add(&mut d, masked); + add(&mut d, rect_node(Rect::new(60.0, 60.0, 80.0, 80.0), Color::BLACK)); + let report = export_with_report(&d, &PdfOptions::uncompressed()).expect("export"); + let imported = import(&report.bytes).expect("import"); + let paths = leaves(&imported).into_iter().filter(|n| n.path_data().is_some()).count(); + (report.warnings, paths) +} + +#[test] +fn missing_colorized_image_in_unclipped_mask_omits_masked_object() { + let (warnings, paths) = image_mask_report(true, false, false, false); + assert!(warnings.iter().any(|w| w == "an image couldn't be decoded and is missing from the picture"), "{warnings:?}"); + assert_eq!(paths, 1); +} + +#[test] +fn missing_plain_image_in_unclipped_mask_omits_masked_object() { + let (warnings, paths) = image_mask_report(false, false, false, false); + assert!(warnings.iter().any(|w| w == "an image couldn't be decoded and is missing from the picture"), "{warnings:?}"); + assert_eq!(paths, 1); +} + +#[test] +fn missing_image_in_nested_mask_omits_outer_masked_object() { + let (warnings, paths) = image_mask_report(false, false, true, false); + assert!(warnings.iter().any(|w| w == "an image couldn't be decoded and is missing from the picture"), "{warnings:?}"); + assert_eq!(paths, 1); +} + +#[test] +fn writable_colorized_image_in_unclipped_mask_keeps_masked_object() { + let (warnings, paths) = image_mask_report(true, true, false, false); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(paths, 2); +} + +#[test] +fn unwritable_images_in_unclipped_masks_omit_masked_objects() { + for colorized in [false, true] { + let (warnings, paths) = image_mask_report(colorized, true, false, true); + assert!(warnings.iter().any(|w| w == "an image couldn't be written and is missing from the picture"), "{warnings:?}"); + assert_eq!(paths, 1, "colorized: {colorized}"); + } +} + #[test] fn empty_and_populated_file_info_preserve_key_presence() { let mut d = doc(100.0, 100.0); diff --git a/crates/pdf/src/tests/import_fidelity.rs b/crates/pdf/src/tests/import_fidelity.rs index 25d84735a..e08d2e74a 100644 --- a/crates/pdf/src/tests/import_fidelity.rs +++ b/crates/pdf/src/tests/import_fidelity.rs @@ -241,6 +241,41 @@ fn isolate_and_knockout_flags_of_a_group_are_kept() { assert!(all(&d).iter().all(|n| !matches!(n.kind, NodeKind::Group { .. }))); } +#[test] +fn knockout_groups_after_a_group_drawn_straight_keep_their_flag() { + // Two knockout groups (ISO 32000-1, 11.4.6.3), each of a full-opacity element and a 50% one, + // both elements groups of their own. A full-opacity element adds nothing as a group and is + // drawn straight onto its parent; the knockout group after it still knocks out. + let x = first_extra(1); + let ring = |at: usize| { + let res = format!("/ExtGState << /A << /ca 1 >> /B << /ca 0.5 >> >> /XObject << /E {} 0 R /F {} 0 R >>", at, at + 1); + stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /K true >> /Resources << {res} >>"), + "q /A gs /E Do Q q /B gs /F Do Q", + ) + }; + let element = |art: &str| stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency >>", art); + let d = open(&one_page( + "/X0 Do /X1 Do", + &format!("/XObject << /X0 {x} 0 R /X1 {} 0 R >>", x + 1), + &[ + &ring(x + 2), + &ring(x + 4), + &element("0 0 1 rg 10 10 40 40 re f"), + &element("0 0 1 rg 20 20 20 20 re f"), + &element("0 0 1 rg 50 50 40 40 re f"), + &element("0 0 1 rg 60 60 20 20 re f"), + ], + )); + let groups: Vec = all(&d).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { .. })).collect(); + assert_eq!(groups.len(), 2, "{groups:?}"); + for g in &groups { + assert_eq!(g.knockout, Knockout::On, "{g:?}"); + let opacities: Vec = g.children().unwrap().iter().map(|c| c.opacity).collect(); + assert_eq!(opacities, [1.0, 0.5]); + } +} + // ---------- tiling patterns ---------- #[test] diff --git a/crates/plugins/example/Cargo.lock b/crates/plugins/example/Cargo.lock index b42995700..a4cd2a3ea 100644 --- a/crates/plugins/example/Cargo.lock +++ b/crates/plugins/example/Cargo.lock @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", diff --git a/crates/render/src/fx/entries.rs b/crates/render/src/fx/entries.rs index cd9acb109..ec223173b 100644 --- a/crates/render/src/fx/entries.rs +++ b/crates/render/src/fx/entries.rs @@ -43,11 +43,12 @@ pub(super) enum KeptImage { /// The image on its own grid, as [`Renderer::rasterize_image`] makes it, and how many images /// in the content failed to decode while it was made ([`crate::paint::decode_failures`]): /// each draw of the image from here counts them again ([`crate::paint::add_decode_failures`]). - Grid(std::rc::Rc<(Vec, usize, usize)>, u64), + /// The last value records whether an image was left out while making the grid. + Grid(std::rc::Rc<(Vec, usize, usize)>, u64, bool), /// An image over [`Renderer::rasterize_image`]'s limits: the content's raster with the /// Rasterize applied, as the first draw made it, and the transform from its pixels to - /// document space. - Raster(Arc, Affine), + /// document space. The remaining values record decode failures and whether an image was left out. + Raster(Arc, Affine, u64, bool), } /// A cached shadow / glow raster: premultiplied, already tinted and faded, positioned relative to @@ -71,6 +72,12 @@ pub(crate) struct ShadowEntry { /// Made for the object before it moved ([`Renderer::move_shadow`]), or made over rasters /// made before their objects moved: only a frame rendered while interacting draws it. pub(super) moved: bool, + /// How many images in the silhouette failed to decode while the raster was made, as + /// [`PixelEntry::failed`]. + pub(super) failed: u64, + /// Whether an image in the silhouette failed to draw while the raster was made, as + /// [`PixelEntry::image_left_out`]. + pub(super) image_left_out: bool, pub(crate) stamp: u64, } diff --git a/crates/render/src/fx/pixels.rs b/crates/render/src/fx/pixels.rs index fbab9afc1..edc844767 100644 --- a/crates/render/src/fx/pixels.rs +++ b/crates/render/src/fx/pixels.rs @@ -40,7 +40,11 @@ impl Renderer { ) { let chain: Vec<&RasterFx> = rfx.iter().filter(content_changing).collect(); // When the first draw of this content kept its raster, that raster is drawn. - if let Some(KeptImage::Raster(image, place)) = content.kept.and_then(|k| k.image.borrow().clone()) { + if let Some(KeptImage::Raster(image, place, failed, image_left_out)) = content.kept.and_then(|k| k.image.borrow().clone()) { + crate::paint::add_decode_failures(failed); + if image_left_out { + self.image_left_out = self.image_left_out.map(|_| true); + } draw_placed(ctx, image, f.view * place); return; } @@ -131,6 +135,9 @@ impl Renderer { let p = f.view * e.anchor; let (image, at, k) = (e.image.clone(), ((p.x + e.rel.0).round(), (p.y + e.rel.1).round()), e.k); crate::paint::add_decode_failures(e.failed); + if e.image_left_out { + self.image_left_out = self.image_left_out.map(|_| true); + } draw_scaled(ctx, image, at, k); return; } @@ -151,23 +158,32 @@ impl Renderer { let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f }; let line = 72.0 / ppi; let (before, decoded) = (self.stats.moved, 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(); // A Rasterize first in the chain draws the content on its image's own grid instead, unless // that image is too large: the content is drawn once. let mut rasterized = match chain.first() { Some(RasterFx::Rasterize(r)) => { let rect = r.image(content.bounds.unwrap_or(content.reach), line); let kept = content.kept.and_then(|k| match &*k.image.borrow() { - Some(KeptImage::Grid(image, failed)) => { + Some(KeptImage::Grid(image, failed, image_left_out)) => { crate::paint::add_decode_failures(*failed); + if *image_left_out { + self.image_left_out = self.image_left_out.map(|_| true); + } Some(image.clone()) } _ => None, }); let image = kept.or_else(|| { let failures = 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(); let image = self.rasterize_image(f, r, rect, paint).map(std::rc::Rc::new); + let left_out = self.image_left_out.unwrap_or(false) || crate::pattern::images_left_out() > pattern_images_left_out; + self.image_left_out = outer_image_left_out.map(|outer| outer || left_out); if let (Some(k), Some(image)) = (content.kept, &image) { - *k.image.borrow_mut() = Some(KeptImage::Grid(image.clone(), crate::paint::decode_failures() - failures)); + *k.image.borrow_mut() = Some(KeptImage::Grid(image.clone(), crate::paint::decode_failures() - failures, left_out)); } image }); @@ -190,6 +206,8 @@ impl Renderer { off.render(&mut pm, &mut self.resources); } 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; + self.image_left_out = outer_image_left_out.map(|outer| outer || image_left_out); // Content drawn over moved rasters makes a moved raster. let moved = self.stats.moved > before; // What the raster is made from, for its object once it moves (`move_pixel_content`): a @@ -258,7 +276,8 @@ impl Renderer { let rel = (x0 - p.x, y0 - p.y); let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node))); let node = Drawn::of(content.node, owner.as_ref()); - let entry = PixelEntry { node, linear, anchor, rel, k, image: image.clone(), view: in_view, made, inputs, moved, failed, stamp }; + let entry = + PixelEntry { node, linear, anchor, rel, k, image: image.clone(), view: in_view, made, inputs, moved, failed, image_left_out, stamp }; self.pixel_fx.insert(key, entry); self.trim_fx_rasters(FX_RASTER_BUDGET); } @@ -266,7 +285,7 @@ impl Renderer { match content.kept.filter(|kept| kept.image.borrow().is_none()) { Some(kept) => { let place = f.view.inverse() * Affine::translate((x0, y0)) * Affine::scale(1.0 / k); - *kept.image.borrow_mut() = Some(KeptImage::Raster(image.clone(), place)); + *kept.image.borrow_mut() = Some(KeptImage::Raster(image.clone(), place, failed, image_left_out)); if f.view == call { draw_scaled(ctx, image, (x0, y0), k) } else { draw_placed(ctx, image, call * place) } } None => draw_scaled(ctx, image, (x0, y0), k), diff --git a/crates/render/src/fx/raster.rs b/crates/render/src/fx/raster.rs index 9589d26a8..262834d9f 100644 --- a/crates/render/src/fx/raster.rs +++ b/crates/render/src/fx/raster.rs @@ -89,7 +89,7 @@ pub(crate) struct PixelEntry { pub(super) anchor: vectorcraft_geom::Point, pub(super) rel: (f64, f64), pub(super) k: f64, - pub(super) image: std::sync::Arc, + pub(crate) image: std::sync::Arc, /// For a raster of the content's whole reach at full size, `None`: it draws after a pan too. /// For one cropped to the view, or made at a lower resolution, the view and the rect it was /// made for: it draws only there, so dragging other art over the object keeps it (a wide blur, @@ -107,6 +107,9 @@ pub(crate) struct PixelEntry { /// ([`crate::paint::decode_failures`]): each frame that draws the raster counts them /// again ([`crate::paint::add_decode_failures`]). pub(super) failed: u64, + /// Whether an image in the content failed to draw while the raster was made: each frame that + /// draws the raster inside opacity-mask art notes it again. + pub(crate) image_left_out: bool, pub(crate) stamp: u64, } diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index 426075b47..434707831 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -62,6 +62,10 @@ impl Renderer { Some(e) => { e.stamp = stamp; self.stats.moved += usize::from(e.moved); + crate::paint::add_decode_failures(e.failed); + if e.image_left_out { + self.image_left_out = self.image_left_out.map(|_| true); + } let p = f.view * e.anchor; (e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved, e.view.map(|v| v.served(f.view))) } @@ -118,7 +122,13 @@ impl Renderer { let ink = darkness.map_or(f.ink, |d| f.ink.darkened(d)); let crop = Frame { mt: false, view, visible, ink, ..*f }; let before = self.stats.moved; + 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(); 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; + self.image_left_out = outer_image_left_out.map(|outer| outer || image_left_out); // A silhouette drawn over moved rasters makes a moved shadow. let moved = self.stats.moved > before; off.flush(); @@ -165,7 +175,10 @@ impl Renderer { let node = Drawn::of(n, owner); let rel = (x0 - p.x, y0 - p.y); let view = cropped.map(|room| ViewCrop::of(f.view, room)); - self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, view, made, inputs, moved, stamp }); + self.shadows.insert( + key, + ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, view, made, inputs, moved, failed, image_left_out, stamp }, + ); self.trim_fx_rasters(FX_RASTER_BUDGET); (image, x0, y0, scale, moved, cropped) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 948d6cacd..2e124769c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -151,6 +151,8 @@ pub struct Renderer { /// The picture [`Self::flatten`] started the current context again from, with 8-bit /// compositing: [`Self::finish`] renders the context over it. picture: Option>, + /// Whether an image failed to draw while opacity-mask art is being drawn. + image_left_out: Option, /// Pixels of the groups being drawn offscreen (contexts and backdrop copies), and the most /// they may hold (set from the frame when the first opens, see [`Self::group`]); both reset /// at the start of every frame. diff --git a/crates/render/src/node_checks.rs b/crates/render/src/node_checks.rs index 6e2be3f2e..07a1b9dc2 100644 --- a/crates/render/src/node_checks.rs +++ b/crates/render/src/node_checks.rs @@ -25,6 +25,23 @@ pub(crate) fn single_plain_fill(n: &Node) -> bool { matches!((painted.next(), painted.next()), (Some(AppearanceItem::Fill(f)), None) if f.blend == vectorcraft_color::BlendMode::Normal && f.opacity >= 1.0 && !matches!(f.paint, vectorcraft_color::Paint::Pattern { .. })) } +/// A knockout element whose opaque paint covers exactly its knockout shape. The caller limits +/// source-over substitution to binary coverage, retaining analytical AA's intermediate rounding. +pub(crate) fn opaque_knockout_fill(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) + && n.opacity >= 1.0 + && n.blend == vectorcraft_color::BlendMode::Normal + && !n.isolate + && n.extras().mask.is_none() + && !crate::fx::has_fx(n) + && !crate::fx::has_object_fx(n) + && single_plain_fill(n) + && n.appearance + .items + .iter() + .any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && matches!(f.paint, vectorcraft_color::Paint::Solid { .. }))) +} + /// Number of visible painted fill/stroke items (opacity folding is exact only for one). A dashed /// stroke is one paint: its dashes are outlined into one path and filled once, so overlapping /// dashes don't darken where they overlap. A dashed brush stroke draws its pieces one by one, so diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index 03c87878e..d87f039f2 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -93,6 +93,9 @@ struct TileEntry { /// ([`crate::paint::decode_failures`]): each frame that draws from the tile counts them /// again ([`crate::paint::add_decode_failures`]). failed: u64, + /// Whether an image in the art failed to draw while the tile was drawn: each frame that draws + /// from the tile counts it again ([`images_left_out`]). + image_left_out: bool, } struct PatternCache { @@ -129,6 +132,13 @@ impl PatternCache { thread_local! { static CACHE: RefCell = RefCell::new(PatternCache::default()); + static IMAGES_LEFT_OUT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many pattern tiles drawn on this thread so far left out an image of their art: opacity-mask +/// art compares the count before and after it draws ([`Renderer::mask_values`]). +pub(crate) fn images_left_out() -> u64 { + IMAGES_LEFT_OUT.with(std::cell::Cell::get) } fn same_art(a: &[Arc], b: &[Arc]) -> bool { @@ -159,12 +169,14 @@ pub(crate) fn raster_scale(s: f64, period: (f64, f64)) -> f64 { /// Rasterize `region` (pattern space) of the infinite tiling into a `w`×`h` pixmap, colours as /// `ink` paints them. -fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w: u16, h: u16, ink: Ink) -> Pixmap { +fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w: u16, h: u16, ink: Ink) -> (Pixmap, bool) { let (w, h) = (w.max(1), h.max(1)); let mut ctx = crate::single_threaded_context(w, h); let view = Affine::scale_non_uniform(w as f64 / region.width().max(1e-9), h as f64 / region.height().max(1e-9)) * Affine::translate(-region.origin().to_vec2()); let opts = RenderOptions::default(); + r.image_left_out = Some(false); + let pattern_images_left_out = images_left_out(); r.check_plugins(); r.check_symbols(doc); let symbols = doc.symbol_boxes().quick(); @@ -188,7 +200,7 @@ fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w r.draw_arc(&mut ctx, &frame, a); } } - r.finish(&mut ctx, r.raster) + (r.finish(&mut ctx, r.raster), r.image_left_out.take().unwrap_or(false) || images_left_out() > pattern_images_left_out) } /// Run `f` with the shared off-screen renderer (None when nested too deep: a pattern whose art @@ -258,22 +270,28 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, && std::ptr::eq(e.cms.as_ptr(), Arc::as_ptr(&cms)) && e.deps.as_ref().is_none_or(|d| d.current(doc)) }) - .map(|e| (e.pixmap.clone(), e.sx, e.sy, e.failed)) + .map(|e| (e.pixmap.clone(), e.sx, e.sy, e.failed, e.image_left_out)) }); let (pm, sx, sy) = match hit { - Some((pm, sx, sy, failed)) => { + Some((pm, sx, sy, failed, image_left_out)) => { crate::paint::add_decode_failures(failed); + if image_left_out { + IMAGES_LEFT_OUT.with(|n| n.set(n.get().saturating_add(1))); + } (pm, sx, sy) } None => { let w = (pw * scale).ceil().clamp(1.0, 4096.0) as u16; let h = (ph * scale).ceil().clamp(1.0, 4096.0) as u16; let before = crate::paint::decode_failures(); - let Some(pm) = with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), w, h, ink)) else { + let Some((pm, image_left_out)) = with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), w, h, ink)) else { ctx.set_paint(missing()); return true; }; let failed = crate::paint::decode_failures() - before; + if image_left_out { + IMAGES_LEFT_OUT.with(|n| n.set(n.get().saturating_add(1))); + } let pm = Arc::new(pm); let (sx, sy) = (w as f64 / pw, h as f64 / ph); let deps = crate::fx::Reads::of_all(def.art.iter().map(|a| &**a)).any().then(|| Deps::of(doc)); @@ -285,7 +303,8 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, let mut c = c.borrow_mut(); let tiles = &mut c.tiles[ink.index()]; let (t, bytes) = (tiles.tick(), size_of::() + w as usize * h as usize * 4); - let entry = TileEntry { art: def.art.clone(), deps, cms: Arc::downgrade(&cms), params, sx, sy, pixmap: pm.clone(), failed }; + let entry = + TileEntry { art: def.art.clone(), deps, cms: Arc::downgrade(&cms), params, sx, sy, pixmap: pm.clone(), failed, image_left_out }; tiles.insert(key, entry, bytes, t); }); (pm, sx, sy) @@ -304,7 +323,7 @@ pub fn render_pattern_swatch(doc: &Document, name: &str, size: u32) -> Option Vec { + pub(super) fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Option> { let mut mctx = f.offscreen_context(w, h); // Mask art is a picture of its own: it takes no part in a knockout group around the object, // and its luminance is that of its screen colours. @@ -187,10 +187,22 @@ impl Renderer { std::mem::take(&mut self.clip_paths), self.picture.take(), ); + let image_left_out = self.image_left_out.replace(false); + let failures = crate::paint::decode_failures(); + let pattern_images_left_out = crate::pattern::images_left_out(); self.draw_kept_node(&mut mctx, &Frame { mt: false, ink: Ink::Display, ..*f }, &m.art); + // Pattern tiles are drawn without undecoded-image marks, so an image a tile left out counts + // with the marks on too. + let incomplete = self.image_left_out.unwrap_or(false) + || crate::pattern::images_left_out() > pattern_images_left_out + || (!f.opts.mark_undecoded_images && crate::paint::decode_failures() > failures); + self.image_left_out = image_left_out.map(|outer| outer || incomplete); let pm = self.finish(&mut mctx, self.raster); (self.knockout, self.nested, self.backdrop, self.clip_paths, self.picture) = outer; - pm.data().iter().map(|p| mask_value(p.r, p.g, p.b, p.a, m.clip, m.invert)).collect() + if incomplete { + return None; + } + Some(pm.data().iter().map(|p| mask_value(p.r, p.g, p.b, p.a, m.clip, m.invert)).collect()) } /// Everything [`Self::draw_arc`] does after culling. @@ -593,6 +605,13 @@ impl Renderer { if self.skipped(f, c) { continue; } + // Hard-edge coverage is binary, so an opaque fill replaces its knockout shape exactly. + // This also serves supersampled exports. Analytical AA keeps the erase/add passes: + // their intermediate rounding can differ from source-over at partially covered edges. + if f.opts.anti_alias == crate::AntiAlias::None && crate::node_checks::opaque_knockout_fill(c) { + self.draw_arc(ctx, f, c); + continue; + } self.knockout_pass(ctx, f, c, Compose::DestOut); match &backdrop { Some(b) => self.knockout_over(ctx, f, c, b), diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 2cd8088d2..14a7b303c 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -748,6 +748,229 @@ fn opacity_mask_without_clip_and_inverted() { assert_eq!(img.pixel(80, 50), [255, 0, 0, 255], "inverted clip background → visible"); } +#[test] +fn opacity_mask_with_undecodable_image_leaves_out_object() { + use vectorcraft_doc::{Effect, ImageBlob, ImageObject, NodeKind, OpacityMask}; + + let mut d = Document::new(100.0, 100.0); + d.images.insert("broken-mask-image".into(), ImageBlob::new("image/png", b"not a PNG".to_vec())); + let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let image = ImageObject { + key: "broken-mask-image".into(), + width: 20, + height: 20, + xf: Affine::translate((30.0, 30.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); + image.appearance.effects.push(Effect { id: "blur.gaussian".into(), params: serde_json::json!({"radius": 4.0}), visible: true }); + let white = rect_node(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + let art = Node::group(d.alloc_id(), vec![Arc::new(white), Arc::new(image)]); + object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + let object_id = object.id; + d.insert(Some(d.layers[0].id), 0, object).unwrap(); + + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let pending = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { kept_images_only: true, ..opts.clone() }); + assert_eq!(pending.pixel(70, 70), [255, 0, 0, 255], "an image awaiting decoding does not invalidate the mask"); + let mut renderer = Renderer::new(); + let img = renderer.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!(img.pixel(70, 70), [255, 255, 255, 255], "missing mask image leaves out the object"); + let again = renderer.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!(again.pixel(70, 70), [255, 255, 255, 255], "the cached raster keeps the mask incomplete"); + let mask_view = renderer.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mask_view: Some(object_id), ..opts.clone() }); + assert_eq!(mask_view.pixel(70, 70), [0, 0, 0, 255], "an incomplete mask view has no coverage"); + let marked = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mark_undecoded_images: true, ..opts.clone() }); + assert_eq!(marked.pixel(70, 70), [255, 0, 0, 255], "the undecoded-image mark remains part of the mask"); + let kept = RenderOptions { kept_images_only: true, ..opts.clone() }; + for frame in 0..2 { + let img = renderer.render(&d, 100, 100, Affine::IDENTITY, &kept); + assert_eq!(img.pixel(70, 70), [255, 255, 255, 255], "an image already known undecodable leaves out the object on frame {frame}"); + } + + let working = Renderer::new().render(&masked_doc(false, false), 100, 100, Affine::IDENTITY, &opts); + assert_eq!(working.pixel(80, 50), [255, 0, 0, 255], "a complete mask still draws the object"); +} + +#[test] +fn opacity_mask_with_missing_image_in_pattern_leaves_out_object() { + use vectorcraft_doc::pattern::{PatternDef, pattern_paint}; + use vectorcraft_doc::{ImageObject, NodeKind, OpacityMask}; + + let mut d = Document::new(100.0, 100.0); + let image = ImageObject { + key: "missing-pattern-image".into(), + width: 20, + height: 20, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); + let mut pattern = PatternDef::new("Missing image", vec![Arc::new(image)]); + pattern.tile = Rect::new(0.0, 0.0, 20.0, 20.0); + d.patterns.push(pattern); + let art = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), pattern_paint("Missing image"), Paint::None, 0.0); + let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + d.insert(Some(d.layers[0].id), 0, object).unwrap(); + + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + for frame in 0..2 { + let img = r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!(img.pixel(70, 50), [255, 255, 255, 255], "frame {frame}: incomplete pattern mask leaves out the object"); + } + // Pattern tiles draw no undecoded-image mark, so the object stays out with the marks on too. + let marked = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mark_undecoded_images: true, ..opts.clone() }); + assert_eq!(marked.pixel(70, 50), [255, 255, 255, 255], "a tile without the mark leaves out the object"); +} + +#[test] +fn complete_image_mask_with_blur_reuses_cached_raster() { + use vectorcraft_doc::{Effect, ImageBlob, ImageObject, NodeKind, OpacityMask}; + + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(20, 20, image::Rgba([255, 255, 255, 255]))) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let mut d = Document::new(100.0, 100.0); + d.images.insert("complete-mask-image".into(), ImageBlob::new("image/png", png)); + let image = ImageObject { + key: "complete-mask-image".into(), + width: 20, + height: 20, + xf: Affine::translate((30.0, 30.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); + image.appearance.effects.push(Effect { id: "blur.gaussian".into(), params: serde_json::json!({"radius": 4.0}), visible: true }); + let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let white = rect_node(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + let art = Node::group(d.alloc_id(), vec![Arc::new(white), Arc::new(image)]); + object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + d.insert(Some(d.layers[0].id), 0, object).unwrap(); + + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let first = r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!(first.pixel(70, 70), [255, 0, 0, 255]); + let raster = r.pixel_fx.values().next().unwrap().image.clone(); + let second = r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!(second.pixels, first.pixels); + assert!(Arc::ptr_eq(&raster, &r.pixel_fx.values().next().unwrap().image), "the second frame draws the image mask from its cached effect raster"); +} + +#[test] +fn missing_image_mask_with_blur_stays_incomplete_from_cached_raster() { + use vectorcraft_doc::{Effect, ImageObject, NodeKind, OpacityMask}; + + let mut d = Document::new(100.0, 100.0); + let image = ImageObject { + key: "missing-blurred-mask-image".into(), + width: 20, + height: 20, + xf: Affine::translate((30.0, 30.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); + image.appearance.effects.push(Effect { id: "blur.gaussian".into(), params: serde_json::json!({"radius": 4.0}), visible: true }); + let white = rect_node(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + let art = Node::group(d.alloc_id(), vec![Arc::new(white), Arc::new(image)]); + let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + d.insert(Some(d.layers[0].id), 0, object).unwrap(); + + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let first = r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!(first.pixel(70, 70), [255, 255, 255, 255]); + let raster = r.pixel_fx.values().next().unwrap().image.clone(); + let second = r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!(second.pixel(70, 70), [255, 255, 255, 255]); + assert!(Arc::ptr_eq(&raster, &r.pixel_fx.values().next().unwrap().image), "the incomplete raster is reused"); +} + +#[test] +fn missing_image_mask_with_rasterize_and_shadow_keeps_incomplete_content_raster() { + use vectorcraft_doc::{Effect, ImageObject, NodeKind, OpacityMask}; + + let mut d = Document::new(100.0, 100.0); + let image = ImageObject { + key: "missing-rasterized-mask-image".into(), + width: 20, + height: 20, + xf: Affine::translate((30.0, 30.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); + image.appearance.effects = [ + ("rasterize", serde_json::json!({"background": "white"})), + ("stylize.dropShadow", serde_json::json!({"mode": "normal", "opacity": 100, "x": 2, "y": 2, "blur": 1, "color": "#0000ff"})), + ("blur.gaussian", serde_json::json!({"radius": 4.0})), + ] + .into_iter() + .map(|(id, params)| Effect { id: id.into(), params, visible: true }) + .collect(); + let white = rect_node(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + let art = Node::group(d.alloc_id(), vec![Arc::new(white), Arc::new(image)]); + let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + d.insert(Some(d.layers[0].id), 0, object).unwrap(); + + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let mut first_raster = None; + for frame in 0..2 { + let img = r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!(img.pixel(70, 70), [255, 255, 255, 255], "frame {frame}: the incomplete mask leaves out the object"); + let raster = r.pixel_fx.values().next().unwrap(); + assert!(raster.image_left_out, "frame {frame}: the content raster records the missing image"); + if let Some(first) = &first_raster { + assert!(Arc::ptr_eq(first, &raster.image), "the second frame reuses the incomplete content raster"); + } else { + first_raster = Some(raster.image.clone()); + } + } +} + +#[test] +fn undecodable_image_in_nested_opacity_mask_leaves_out_outer_object() { + use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind, OpacityMask}; + + let mut d = Document::new(100.0, 100.0); + d.images.insert("broken-nested-mask-image".into(), ImageBlob::new("image/png", b"not a nested PNG".to_vec())); + let mut outer = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let mut inner = rect_node(&mut d, Rect::new(20.0, 20.0, 80.0, 80.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + let image = ImageObject { + key: "broken-nested-mask-image".into(), + width: 20, + height: 20, + xf: Affine::translate((30.0, 30.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); + inner.extras_mut().mask = Some(Box::new(OpacityMask::new(image, false))); + let art = Node::group(d.alloc_id(), vec![Arc::new(inner)]); + outer.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + d.insert(Some(d.layers[0].id), 0, outer).unwrap(); + + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!(img.pixel(70, 70), [255, 255, 255, 255], "the incomplete inner mask invalidates the outer mask"); +} + /// #3580: each opacity mask is rendered over its object's pixels, not over the whole frame. 300 /// masked 4 × 4 pt squares on a 1000 × 1000 page draw offscreen in proportion to their bounds, and /// still draw where their masks show them. diff --git a/crates/render/src/tests/alternate.rs b/crates/render/src/tests/alternate.rs index ad39ed065..8ef690330 100644 --- a/crates/render/src/tests/alternate.rs +++ b/crates/render/src/tests/alternate.rs @@ -118,7 +118,9 @@ fn arabic_joining_forms_are_not_alternates() { assert_eq!(alternates(&t), 0); } -/// #394: the lam-alef ligature is required (`rlig`), so it isn't highlighted either. +/// #394: the lam-alef ligature is required (`rlig`), so it isn't highlighted either. With an +/// older font that files it under `liga` (as the legacy Arial of Microsoft's core fonts does) it +/// is still required: [`lam_alef_in_liga_is_not_an_alternate`] checks that without an OS font. #[test] fn lam_alef_is_not_an_alternate() { // Looking up an installed font loads it, which changes the fonts. @@ -129,6 +131,38 @@ fn lam_alef_is_not_an_alternate() { } } +/// #394: a font whose lam-alef ligature is in `liga` (older Arabic fonts) or `rlig` gets no +/// highlight on it: the ligature is required by the characters, not chosen by an optional +/// feature. Built in code, so it runs whatever fonts the machine has. +#[test] +fn lam_alef_in_liga_is_not_an_alternate() { + use vectorcraft_text::test_fonts::{lam_alef_family, lam_alef_font}; + let _adding = crate::tests_fontchange::adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + for feature in [b"liga", b"rlig"] { + db.add_font(lam_alef_font(feature).unwrap()); + let style = vectorcraft_doc::text::CharStyle { size: 36.0, font_family: lam_alef_family(feature).into(), ..Default::default() }; + for s in ["\u{0644}\u{0627}", "\u{0644}\u{0623}", "\u{0628}\u{0644}\u{0627}", "\u{0644}\u{064E}\u{0627}"] { + let t = text(s, style.clone()); + // The font's ligature glyph stands for the pair. + assert!(glyphs(&t).iter().any(|g| g.1 > 2), "{}: {s} is shaped to one ligature glyph", lam_alef_family(feature)); + assert_eq!(alternates(&t), 0, "{}: {s}", lam_alef_family(feature)); + } + } +} + +/// Only a lam followed by an alef (marks between or after allowed) is the required lam-alef. +#[test] +fn only_lam_alef_clusters_are_the_required_ligature() { + for s in ["\u{0644}\u{0627}", "\u{0644}\u{0622}", "\u{0644}\u{0623}", "\u{0644}\u{0625}", "\u{0644}\u{064E}\u{0627}", "\u{0644}\u{0627}\u{064B}"] + { + assert!(crate::text_geom::is_lam_alef(s), "{s:?}"); + } + for s in ["", "\u{0644}", "\u{0627}\u{0644}", "\u{0644}\u{0628}", "\u{0628}\u{0644}\u{0627}", "\u{0644}\u{0627}\u{0627}", "fi", "\u{064E}"] { + assert!(!crate::text_geom::is_lam_alef(s), "{s:?}"); + } +} + /// #394: an optional feature the characters turn on (a stylistic set, swash, stylistic or /// discretionary alternate…) is highlighted on the glyphs it changes, and only on those. #[test] diff --git a/crates/render/src/tests/knockout.rs b/crates/render/src/tests/knockout.rs index a32f0de9e..2a7e51e81 100644 --- a/crates/render/src/tests/knockout.rs +++ b/crates/render/src/tests/knockout.rs @@ -184,3 +184,97 @@ fn page_knockout_looks_the_same_when_another_object_blends() { assert!(close(a, b), "the group's middle over {below:?}: {a:?} alone, {b:?} with the Multiply square"); } } + +#[test] +fn opaque_knockout_fills_keep_pixels_without_per_element_backdrop_rasters() { + // Fractional curved edges and holes exercise coverage, not just opaque interiors. A disabled + // mask is visually neutral and keeps the general knockout path as an oracle, without adding + // another compositing layer or rounding step to each fill. + for precise in [false, true] { + for (anti_alias, supersample) in [(AntiAlias::Art, false), (AntiAlias::Art, true), (AntiAlias::None, false)] { + for copy_backdrop in [false, true] { + let make = |reference: bool| { + let mut d = Document::new(80.0, 60.0); + let mut members = Vec::new(); + for i in 0..12 { + let outer = shapes::ellipse(Rect::new(2.3 + f64::from(i), 3.7, 65.4, 53.2)); + let inner = shapes::ellipse(Rect::new(20.2, 20.8, 40.6, 38.1)); + let child = Node::path(d.alloc_id(), inner, Appearance::default()); + let first = Node::path(d.alloc_id(), outer, Appearance::default()); + let mut n = if i % 2 == 0 { + Node::new(d.alloc_id(), NodeKind::Compound { children: vec![Arc::new(first), Arc::new(child)], rule: FillRule::EvenOdd }) + } else { + first + }; + n.appearance = Appearance::basic(Paint::solid(Color::rgb(i as f32 / 12.0, 0.3, 0.7)), Paint::None, 0.0).into(); + if reference { + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 60.0)), Appearance::default()); + let mut mask = vectorcraft_doc::OpacityMask::new(art, false); + mask.disabled = true; + n.extras_mut().mask = Some(Box::new(mask)); + } + members.push(Arc::new(n)); + } + if copy_backdrop { + let mut blend = half(&mut d, 70.0, (0.5, 0.5, 0.5)); + blend.blend = Blend::Multiply; + members.push(Arc::new(blend)); + } + let mut g = Node::group(d.alloc_id(), members); + g.knockout = Knockout::On; + g.opacity = 0.5; + d.insert(Some(d.layers[0].id), 0, g).unwrap(); + d + }; + let opts = RenderOptions { background: Some([190, 210, 230, 255]), precise, anti_alias, supersample, ..Default::default() }; + let draw = |reference| { + crate::group::PIXELS_DRAWN.with(|n| n.set(0)); + let mut r = Renderer::new(); + r.threads = 0; + let img = r.render(&make(reference), 80, 60, Affine::IDENTITY, &opts); + let pixels = crate::group::PIXELS_DRAWN.with(|n| n.get()); + (img, pixels, r.stats.drawn) + }; + let (fast, fast_pixels, fast_draws) = draw(false); + let (reference, reference_pixels, reference_draws) = draw(true); + assert!(fast.pixels == reference.pixels, "precise={precise}, aa={anti_alias:?}, supersample={supersample}, backdrop={copy_backdrop}"); + if anti_alias == AntiAlias::None || supersample { + assert!(fast_draws < reference_draws, "opaque elements should draw once"); + if copy_backdrop { + assert!(fast_pixels < reference_pixels, "opaque elements should not rasterize a backdrop per element"); + } + } else { + assert_eq!(fast_draws, reference_draws, "analytical AA must keep its rounding"); + assert_eq!(fast_pixels, reference_pixels); + } + } + } + } +} + +#[test] +fn translucent_knockout_objects_and_fills_keep_their_compositing() { + for fill_opacity in [false, true] { + let d = doc(Knockout::On, |_, g| { + if fill_opacity { + for n in g.children_mut().unwrap() { + let n = Arc::make_mut(n); + n.opacity = 1.0; + for item in &mut n.appearance.items { + if let vectorcraft_doc::AppearanceItem::Fill(fill) = item { + fill.opacity = 0.5; + } + } + } + } + }); + let opts = RenderOptions { anti_alias: AntiAlias::None, background: Some([255; 4]), ..Default::default() }; + let mut renderer = Renderer::new(); + let r = renderer.render(&d, 100, 100, Affine::IDENTITY, &opts); + // Object opacity is ignored by its knockout shape; a fill's own opacity is retained. + let want = if fill_opacity { BLUE_OVER_RED } else { BLUE_OVER_WHITE }; + assert!(close(r.pixel(50, 50), want), "fill opacity={fill_opacity}: {:?}", r.pixel(50, 50)); + let nodes = d.layers.iter().map(|n| n.count()).sum::(); + assert!(renderer.stats.drawn > nodes, "translucent elements keep the separate knockout passes"); + } +} diff --git a/crates/render/src/text_geom.rs b/crates/render/src/text_geom.rs index 1408b397b..bf058d75f 100644 --- a/crates/render/src/text_geom.rs +++ b/crates/render/src/text_geom.rs @@ -48,7 +48,8 @@ impl TextGeom { /// The boxes of the glyphs of `t` (the text this geometry is of) that optional OpenType features /// chose (Preferences › Highlight Alternate Glyphs): candidates the text's required shaping /// alone doesn't give ([`required_shaping`]). Arabic letters joining, Indic conjuncts and the - /// lam-alef ligature are what the script needs, not alternates. Measured once. + /// lam-alef ligature are what the script needs, not alternates (the lam-alef ligature also when + /// a font files it under `liga`: [`is_lam_alef`]). Measured once. pub(crate) fn alternate_glyphs(&self, t: &TextObject) -> &BezPath { self.alternate_glyphs.get_or_init(|| { let mut out = BezPath::new(); @@ -84,6 +85,17 @@ pub(crate) fn is_opentype_alternate(nominal_gid: u32, shaped_gid: u32, cluster_c shaped_gid != 0 && (cluster_chars > 1 || shaped_gid != nominal_gid) } +/// Whether `cluster` is a lam followed by an alef (plain, with madda, or with hamza above or below), +/// with any Arabic marks between or after them: the lam-alef ligature, which the Unicode +/// Standard's Arabic chapter treats as obligatory, not a stylistic choice. Fonts made before +/// `rlig` was common put it in `liga`, which [`required_shaping`] turns off, so it is required +/// by its characters whatever feature the font files it under. +pub(crate) fn is_lam_alef(cluster: &str) -> bool { + let is_mark = |c: &char| matches!(c, '\u{0610}'..='\u{061A}' | '\u{064B}'..='\u{065F}' | '\u{0670}' | '\u{06D6}'..='\u{06DC}' | '\u{06DF}'..='\u{06E4}' | '\u{06E7}' | '\u{06E8}' | '\u{06EA}'..='\u{06ED}'); + let mut letters = cluster.chars().filter(|c| !is_mark(c)); + matches!((letters.next(), letters.next(), letters.next()), (Some('\u{0644}'), Some('\u{0622}' | '\u{0623}' | '\u{0625}' | '\u{0627}'), None)) +} + /// A glyph [`is_opentype_alternate`] marks, in its cluster of the text (`byte`, `len`), with its /// box. #[derive(Debug)] @@ -181,7 +193,7 @@ pub(crate) fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeo let cluster_chars = cluster.chars().count(); let Some(first) = cluster.chars().next() else { continue }; let nominal = db.face_by_id(g.font_id).map(|f| f.glyph_for(first)).unwrap_or(0); - if is_opentype_alternate(nominal, g.gid, cluster_chars) { + if is_opentype_alternate(nominal, g.gid, cluster_chars) && !is_lam_alef(cluster) { alternate_candidates.push(AlternateCandidate { byte: g.byte, len: g.len, font_id: g.font_id, gid: g.gid, cell }); } } diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index b76159c2c..d6319a9c6 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -111,7 +111,13 @@ mod tests { fn a_temp_dir_starts_empty_in_a_process_and_stays_for_later_calls() { let tag = "fresh-temp-dir"; let left = std::env::temp_dir().join(format!("vectorcraft-testkit-{tag}-{}", std::process::id())); - std::fs::create_dir_all(left.join("nested")).unwrap(); + // Made private, as `temp_dir` makes it: a directory others can reach is refused, not reused. + #[cfg_attr(not(unix), allow(unused_mut))] + let mut b = std::fs::DirBuilder::new(); + b.recursive(true); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700); + b.create(left.join("nested")).unwrap(); std::fs::write(left.join("left.txt"), b"an earlier process").unwrap(); let dir = super::temp_dir(tag); assert_eq!(dir, left); diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 76f6603ff..449221f9f 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -343,6 +343,56 @@ pub fn half_form_font(half: bool) -> Option> { Some(builder.build()) } +/// The family of [`lam_alef_font`] whose lam-alef ligature is in `feature`. +pub fn lam_alef_family(feature: &[u8; 4]) -> &'static str { + match feature { + b"liga" => "Lamtest Liga", + b"rlig" => "Lamtest Rlig", + _ => "Lamtest Other", + } +} + +/// A test-only Arabic cmap over bundled Source Sans 3: lam uses l, alef I, alef with hamza above +/// J and beh b. The `GSUB` table's `arab` script has one ligature lookup, under `feature`, that +/// joins lam + alef into H and lam + alef with hamza above into K. Older fonts put the mandatory +/// lam-alef ligature in `liga` rather than `rlig`; this builds either, without an OS font. `None` +/// when it can't be built. +pub fn lam_alef_font(feature: &[u8; 4]) -> Option> { + use write_fonts::tables::cmap::Cmap; + use write_fonts::tables::gsub::{Gsub, Ligature, LigatureSet, LigatureSubstFormat1, SubstitutionLookup, SubstitutionLookupList}; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let charmap = font.charmap(); + let mapping = [('\u{0644}', 'l'), ('\u{0627}', 'I'), ('\u{0623}', 'J'), ('\u{0628}', 'b')]; + let mappings = mapping.into_iter().map(|(c, source)| Some((c, write_fonts::types::GlyphId::new(charmap.map(source)?.to_u32())))); + let cmap = Cmap::from_mappings(mappings.collect::>>()?).ok()?; + let family = lam_alef_family(feature); + let full = format!("{family} Regular"); + let postscript = format!("{}-Regular", family.replace(' ', "")); + let name = name_table(&[(1, family), (2, "Regular"), (4, &full), (6, &postscript)])?; + let mut builder = FontBuilder::new(); + builder.add_table(&cmap).ok()?; + builder.add_raw(Tag::new(b"name"), name); + let gid = |c| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?)); + let coverage = CoverageFormat1::new(vec![gid('l')?]); + let ligatures = vec![Ligature::new(gid('H')?, vec![gid('I')?]), Ligature::new(gid('K')?, vec![gid('J')?])]; + let subst = LigatureSubstFormat1::new(coverage.into(), vec![LigatureSet::new(ligatures)]); + let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(feature), Feature::new(None, vec![0]))]); + let lookups = SubstitutionLookupList::new(vec![SubstitutionLookup::Ligature(Lookup::new(Default::default(), vec![subst]))]); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"arab"), Script::new(Some(LangSys::new(vec![0])), vec![]))]); + builder.add_table(&Gsub::new(scripts, features, lookups)).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`pair_kerning_font`]. pub const PAIR_KERNING_FAMILY: &str = "Pairtest Sans"; diff --git a/crates/tools/src/addons.rs b/crates/tools/src/addons.rs index d10e38a3b..aff7069a5 100644 --- a/crates/tools/src/addons.rs +++ b/crates/tools/src/addons.rs @@ -3,9 +3,12 @@ //! submenu selects them. Like every tool they only emit commands (`addon..*`). pub mod anchor_eraser; +pub mod corner_tool; pub mod extend_path; pub mod measure_inspect; pub mod parametric_shape; +pub mod shared_edge; +pub mod slide_anchor; pub mod smart_pen; use crate::{Tool, ToolInfo}; @@ -16,6 +19,9 @@ pub const PATH_TOOLS: &[ToolInfo] = &[ ToolInfo { id: extend_path::ID, label: "Extend Path Tool", shortcut: None, icon: "dc-arrow-extend" }, ToolInfo { id: measure_inspect::ID, label: "Measure & Inspect Tool", shortcut: None, icon: "ruler" }, ToolInfo { id: parametric_shape::ID, label: "Parametric Shape Tool", shortcut: None, icon: "hexagon" }, + ToolInfo { id: corner_tool::ID, label: "Corner Tool", shortcut: None, icon: "dc-rounded-rect" }, + ToolInfo { id: slide_anchor::ID, label: "Slide Anchor Along Path", shortcut: None, icon: "dc-anchor" }, + ToolInfo { id: shared_edge::ID, label: "Shared Edge Merge Tool", shortcut: None, icon: "dc-join" }, ]; /// Smart Pen's tool, a flyout group of its own. @@ -37,9 +43,12 @@ pub fn heading(id: &str) -> Option<&'static str> { pub fn create(id: &str) -> Option> { Some(match id { anchor_eraser::ID => Box::new(anchor_eraser::AnchorEraserTool::default()), + corner_tool::ID => Box::new(corner_tool::CornerTool::default()), extend_path::ID => Box::new(extend_path::ExtendPathTool::default()), measure_inspect::ID => Box::new(measure_inspect::MeasureInspectTool::default()), parametric_shape::ID => Box::new(parametric_shape::ParametricShapeTool::default()), + shared_edge::ID => Box::new(shared_edge::SharedEdgeTool::default()), + slide_anchor::ID => Box::new(slide_anchor::SlideAnchorTool::default()), smart_pen::ID => Box::new(smart_pen::SmartPenTool::default()), _ => return None, }) diff --git a/crates/tools/src/addons/corner_tool.rs b/crates/tools/src/addons/corner_tool.rs new file mode 100644 index 000000000..177926837 --- /dev/null +++ b/crates/tools/src/addons/corner_tool.rs @@ -0,0 +1,387 @@ +//! Corner Tool (Path Precision Tools): editable corners on any path, selected or not, through the +//! app's Live Corners (`addon.pathtools.cornerSet` / `cornerRemove`). Over an anchor that can take +//! a corner, the anchor shows; a click cuts one with the tool's radius and kind, a drag sizes it +//! (Shift: whole values of the ruler's unit). A cut corner shows a marker at the centre of its cut: +//! a click selects the corner and loads its radius and kind into the tool, a drag resizes it (to 0 +//! it goes), a double-click removes it. A corner can't grow past what fits. Alt-marquee cuts +//! corners on every anchor inside; a plain marquee selects them, and a click away deselects. + +use std::cell::Cell; +use std::collections::BTreeMap; +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, LiveCorners, NodeId, NodeKind}; +use vectorcraft_geom::corners::Corner; +use vectorcraft_geom::shapes::CornerKind; +use vectorcraft_geom::{Affine, Point, Rect}; + +use crate::builder::HIGHLIGHT_RED; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +/// The tool's id. +pub const ID: &str = "cornerTool"; +/// The radius a click cuts by default (pt). +pub const RADIUS: f64 = 10.0; +/// The largest radius asked for (pt). +pub const MAX_RADIUS: f64 = 1e6; +/// The undo step of a drag. +const LABEL: &str = "Corner Radius"; +const SET: &str = "addon.pathtools.cornerSet"; +const REMOVE: &str = "addon.pathtools.cornerRemove"; +/// How far (px) the pointer travels before a press is a drag. +const DRAG_PX: f64 = 3.0; +/// Most markers drawn at once. +const MAX_MARKERS: usize = 2000; +const MARKER_PX: f32 = 7.0; +const BLUE: [u8; 3] = [0x4f, 0x9d, 0xff]; + +/// A corner the tool can act on. +#[derive(Clone, Debug, PartialEq)] +struct Spot { + id: NodeId, + corner: Corner, + /// Maps the corner's space into the document. + xf: Affine, + /// The radius it is drawn with: 0 for a sharp corner. + drawn: f64, + kind: CornerKind, +} + +impl Spot { + fn of(lc: &LiveCorners, id: NodeId, corner: &Corner) -> Self { + Self { id, corner: *corner, xf: lc.xf, drawn: lc.drawn_radius(corner.index), kind: lc.kind(corner.index) } + } + + /// Where it shows: a cut corner's marker, the centre of a round cut, or a sharp corner's anchor. + fn at(&self) -> Point { + self.xf * self.corner.on_bisector(self.drawn / self.corner.sin()) + } + + /// The params naming it. + fn items(&self) -> Value { + json!([{ "id": self.id.0, "anchors": [self.corner.index] }]) + } +} + +/// Every cut corner of the editable paths. +fn cut_corners(doc: &Document) -> Vec { + let editable = doc.editable(); + let mut out = vec![]; + doc.walk(|n| { + if matches!(n.kind, NodeKind::Path { live: Some(_), guide: false, .. }) + && editable(n.id) + && let Some(lc) = LiveCorners::of(n) + { + out.extend(lc.corners.iter().filter(|c| lc.drawn_radius(c.index) > 0.0).map(|c| Spot::of(&lc, n.id, c))); + } + }); + out +} + +/// The sharp corner of an editable path nearest `p` within the point tolerance. +fn sharp_corner_at(cx: &ToolContext, p: Point) -> Option { + let (tol, editable) = (cx.point_tol(), cx.doc.editable()); + let mut best: Option<(f64, Spot)> = None; + cx.doc.walk(|n| { + let NodeKind::Path { path, guide: false, .. } = &n.kind else { return }; + if !path.anchors().any(|(_, _, a)| a.p.distance(p) <= tol) || !editable(n.id) { + return; + } + let Some(lc) = LiveCorners::of(n) else { return }; + for c in lc.corners.iter().filter(|c| lc.drawn_radius(c.index) <= 0.0) { + let s = Spot::of(&lc, n.id, c); + let d = s.at().distance(p); + if d <= tol && best.as_ref().is_none_or(|b| d < b.0) { + best = Some((d, s)); + } + } + }); + best.map(|(_, s)| s) +} + +/// The corners of the editable paths inside `r`, by path: anchor indices of each uncut outline. +fn corners_inside(cx: &ToolContext, r: Rect) -> BTreeMap> { + let editable = cx.doc.editable(); + let mut out = BTreeMap::new(); + cx.doc.walk(|n| { + if !matches!(n.kind, NodeKind::Path { guide: false, .. }) || !editable(n.id) { + return; + } + let Some(lc) = LiveCorners::of(n) else { return }; + let inside: Vec = lc.corners.iter().filter(|c| r.contains(lc.xf * c.at)).map(|c| c.index).collect(); + if !inside.is_empty() { + out.insert(n.id, inside); + } + }); + out +} + +/// The largest radius `spot` draws with the corners around it as they are. +fn max_radius(doc: &Document, spot: &Spot) -> f64 { + let Some(lc) = doc.node(spot.id).and_then(LiveCorners::of) else { return 0.0 }; + let radii: Vec = (0..lc.base.anchor_count()).map(|k| lc.drawn_radius(k)).collect(); + spot.corner.max_shared_radius(&lc.corners, &radii, |k| k == spot.corner.index) +} + +/// `select.anchors` params selecting `corners` of path `id`. +fn select_params(cx: &ToolContext, id: NodeId, corners: &[usize], mode: &str) -> Option { + let lc = cx.doc.node(id).and_then(LiveCorners::of)?; + let anchors: Vec<_> = lc.anchors_of(&corners.iter().copied().collect()).into_iter().collect(); + Some(json!({ "id": id.0, "anchors": anchors, "mode": mode })) +} + +/// A document revision and the cut corners it has. +type Marks = ((u64, u64), Arc>); + +#[derive(Clone, Debug, Default)] +enum State { + #[default] + Idle, + /// Pressed on a corner: a drag sizes it. + Corner { + spot: Box, + start: Point, + /// The largest radius that fits. + max: f64, + /// The drag has left the click. + began: bool, + /// The radius the drag asks for. + radius: f64, + /// Where the pointer is. + at: Point, + }, + /// `cut`: an Alt-marquee, which cuts corners; else it selects them. + Marquee { start: Point, cur: Point, cut: bool, toggle: bool }, +} + +pub struct CornerTool { + radius: f64, + kind: CornerKind, + state: State, + hover: Option, + /// The cut corners of the document as it was at a revision, for hovering and the markers. + cache: Cell>, +} + +impl Default for CornerTool { + fn default() -> Self { + Self { radius: RADIUS, kind: CornerKind::Round, state: State::Idle, hover: None, cache: Cell::new(None) } + } +} + +impl CornerTool { + fn markers(&self, cx: &ToolContext) -> Arc> { + let kept = self.cache.take().filter(|(rev, _)| *rev == cx.revision); + let list = kept.map_or_else(|| Arc::new(cut_corners(cx.doc)), |(_, list)| list); + self.cache.set(Some((cx.revision, list.clone()))); + list + } + + /// The corner at `p`: a cut corner's marker, else a sharp corner's anchor. + fn spot_at(&self, cx: &ToolContext, p: Point) -> Option { + let (marks, tol) = (self.markers(cx), cx.point_tol()); + let dist = |s: &&Spot| s.at().distance(p); + let mark = marks.iter().filter(|s| dist(s) <= tol).min_by(|a, b| dist(a).total_cmp(&dist(b))); + mark.cloned().or_else(|| sharp_corner_at(cx, p)) + } + + /// The command a drag of `spot` to `radius` stands for. + fn sized(&self, spot: &Spot, radius: f64) -> Action { + if radius > 0.0 { + let kind = if spot.drawn > 0.0 { spot.kind } else { self.kind }; + Action::Preview(SET.into(), json!({ "items": spot.items(), "radius": radius, "kind": kind })) + } else { + Action::Preview(REMOVE.into(), json!({ "items": spot.items() })) + } + } + + fn drag_corner(&mut self, cx: &ToolContext, p: Point, mods: Mods) -> Vec { + let State::Corner { spot, start, max, began, radius, at } = &mut self.state else { return vec![] }; + *at = p; + let mut out = vec![]; + if !*began { + if p.distance(*start) < cx.tol(DRAG_PX) { + return out; + } + *began = true; + out.push(Action::Begin(LABEL.into())); + } + let inv = spot.xf.inverse(); + let change = spot.corner.radius_change(inv * p - inv * *start); + // From a sharp corner a drag either way sizes it; a cut one grows inward and shrinks back. + let asked = if spot.drawn > 0.0 { spot.drawn + change } else { change.abs() }; + let asked = if mods.shift { cx.unit.to_pt(cx.unit.from_pt(asked).round()) } else { asked }; + *radius = asked.min(*max).max(0.0); + let (spot, r) = (spot.clone(), *radius); + out.push(self.sized(&spot, r)); + out + } + + /// A press on a corner let go without a drag. + fn click_corner(&self, cx: &ToolContext, spot: &Spot, mods: Mods) -> Vec { + if spot.drawn <= 0.0 { + return vec![Action::Exec(SET.into(), json!({ "items": spot.items(), "radius": self.radius, "kind": self.kind }))]; + } + let mode = if mods.shift { "toggle" } else { "set" }; + select_params(cx, spot.id, &[spot.corner.index], mode).map_or_else(Vec::new, |p| vec![Action::Exec("select.anchors".into(), p)]) + } + + fn finish_marquee(&self, cx: &ToolContext, r: Rect, cut: bool, toggle: bool) -> Vec { + let inside = corners_inside(cx, r); + if cut { + if inside.is_empty() { + return vec![]; + } + let items: Vec = inside.iter().map(|(id, corners)| json!({ "id": id.0, "anchors": corners })).collect(); + return vec![Action::Exec(SET.into(), json!({ "items": items, "radius": self.radius, "kind": self.kind }))]; + } + if inside.is_empty() && !toggle { + return vec![Action::Exec("select.none".into(), json!({}))]; + } + let modes = std::iter::once(if toggle { "toggle" } else { "set" }).chain(std::iter::repeat(if toggle { "toggle" } else { "add" })); + inside + .iter() + .zip(modes) + .filter_map(|((id, corners), mode)| Some(Action::Exec("select.anchors".into(), select_params(cx, *id, corners, mode)?))) + .collect() + } +} + +impl Tool for CornerTool { + fn id(&self) -> &'static str { + ID + } + + fn busy(&self) -> bool { + !matches!(self.state, State::Idle) + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + match ev.kind { + PointerKind::Move => { + self.hover = self.spot_at(cx, p); + vec![] + } + PointerKind::Down => { + self.hover = None; + let spot = if ev.mods.alt { None } else { self.spot_at(cx, p) }; + self.state = match spot { + Some(spot) => { + // A cut corner's values are the tool's from now on, as the panel shows. + if spot.drawn > 0.0 { + (self.radius, self.kind) = (spot.drawn, spot.kind); + } + let max = max_radius(cx.doc, &spot); + State::Corner { radius: spot.drawn, spot: Box::new(spot), start: p, max, began: false, at: p } + } + None => State::Marquee { start: p, cur: p, cut: ev.mods.alt, toggle: ev.mods.shift }, + }; + vec![] + } + PointerKind::Drag => { + if let State::Marquee { cur, .. } = &mut self.state { + *cur = p; + return vec![]; + } + self.drag_corner(cx, p, ev.mods) + } + PointerKind::Up => match std::mem::take(&mut self.state) { + State::Idle => vec![], + State::Marquee { start, cut, toggle, .. } => self.finish_marquee(cx, Rect::from_points(start, p), cut, toggle), + state @ State::Corner { began: true, .. } => { + self.state = state; + let mut out = self.drag_corner(cx, p, ev.mods); + if let State::Corner { spot, radius, .. } = std::mem::take(&mut self.state) { + // Back where it started: nothing to keep. + if spot.drawn <= 0.0 && radius <= 0.0 { + return vec![Action::Cancel]; + } + } + out.push(Action::Commit); + out + } + State::Corner { spot, .. } => self.click_corner(cx, &spot, ev.mods), + }, + PointerKind::DoubleClick => { + self.state = State::Idle; + match self.spot_at(cx, p).filter(|s| s.drawn > 0.0) { + Some(s) => vec![Action::Exec(REMOVE.into(), json!({ "items": s.items() }))], + None => vec![], + } + } + } + } + + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key != ToolKey::Escape { + return vec![]; + } + match std::mem::take(&mut self.state) { + State::Corner { began: true, .. } => vec![Action::Cancel], + _ => vec![], + } + } + + fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { + self.busy() && key == ToolKey::Escape + } + + fn hover_ended(&mut self, _cx: &ToolContext) { + self.hover = None; + } + + fn overlays(&self, cx: &ToolContext) -> Vec { + let ring = |p, color, filled| Overlay::CurvePoint { p, color, filled, size: MARKER_PX }; + match &self.state { + State::Marquee { start, cur, .. } => return vec![Overlay::Marquee(Rect::from_points(*start, *cur))], + State::Corner { began: true, radius, max, at, .. } => { + let mut out = vec![]; + if cx.measurement_labels { + out.push(Overlay::Measure { p: *at, text: format!("Radius: {}", cx.len(*radius)) }); + } + // At the limit of what fits. + if *max > 0.0 && *radius >= *max { + out.push(ring(*at, HIGHLIGHT_RED, false)); + } + return out; + } + _ => {} + } + let mut out: Vec = self.markers(cx).iter().take(MAX_MARKERS).map(|s| ring(s.at(), BLUE, false)).collect(); + match &self.hover { + Some(s) if s.drawn > 0.0 => out.push(ring(s.at(), BLUE, true)), + Some(s) => out.push(Overlay::Anchor { p: s.at(), color: BLUE, filled: true, size: MARKER_PX }), + None => {} + } + out + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { + Cursor::CornerTool + } + + fn options(&self) -> Value { + json!({ "radius": self.radius, "kind": self.kind }) + } + + fn set_option(&mut self, key: &str, v: &Value) { + match key { + "radius" => { + if let Some(r) = v.as_f64().filter(|r| (0.0..=MAX_RADIUS).contains(r)) { + self.radius = r; + } + } + "kind" => { + if let Ok(k) = serde_json::from_value(v.clone()) { + self.kind = k; + } + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/tools/src/addons/corner_tool/tests.rs b/crates/tools/src/addons/corner_tool/tests.rs new file mode 100644 index 000000000..de03df742 --- /dev/null +++ b/crates/tools/src/addons/corner_tool/tests.rs @@ -0,0 +1,161 @@ +use vectorcraft_doc::corners::set_corners; +use vectorcraft_doc::{Appearance, Node, Selection}; +use vectorcraft_geom::{PathData, SubPath}; + +use super::*; +use crate::testutil::*; + +/// A square (100, 100)–(300, 300), plain: anchors from the top left, clockwise. +fn doc_with_square() -> (Document, NodeId) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let pts = [(100.0, 100.0), (300.0, 100.0), (300.0, 300.0), (100.0, 300.0)].map(|(x, y)| Point::new(x, y)); + d.insert(Some(l), 0, Node::path(id, PathData::single(SubPath::polyline(&pts, true)), Appearance::default_art())).unwrap(); + (d, id) +} + +/// [`doc_with_square`] with a chamfer of radius 20 on corner 1, whose marker is at (280, 120). +fn doc_with_corner() -> (Document, NodeId) { + let (mut d, id) = doc_with_square(); + let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { panic!() }; + set_corners(path, live, &[1].into(), Some(20.0), Some(CornerKind::Chamfer)); + (d, id) +} + +fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent::new(kind, x, y) +} + +fn with(mut e: PointerEvent, f: impl Fn(&mut Mods)) -> PointerEvent { + f(&mut e.mods); + e +} + +fn radius_of(a: Option<&Action>) -> f64 { + let Some(Action::Preview(_, p)) = a else { panic!("{a:?}") }; + p["radius"].as_f64().unwrap() +} + +#[test] +fn hover_shows_the_anchor_and_a_click_cuts_with_the_tools_values() { + let (d, id) = doc_with_square(); + let (sel, p) = (Selection::default(), paint()); + let cx = cx(&d, &sel, &p); + let mut t = CornerTool::default(); + t.set_option("radius", &json!(15)); + t.set_option("kind", &json!("invertedRound")); + assert!(t.pointer(&cx, &ev(PointerKind::Move, 301.0, 101.0)).is_empty()); + assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::Anchor { filled: true, .. }]), "{:?}", t.overlays(&cx)); + t.pointer(&cx, &ev(PointerKind::Move, 200.0, 200.0)); + assert!(t.overlays(&cx).is_empty(), "nothing there"); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 300.0, 100.0)).is_empty()); + assert!(t.busy()); + let up = t.pointer(&cx, &ev(PointerKind::Up, 300.0, 100.0)); + assert_eq!(up, vec![Action::Exec(SET.into(), json!({"items": [{"id": id.0, "anchors": [1]}], "radius": 15.0, "kind": "invertedRound"}))]); + assert!(!t.busy()); +} + +#[test] +fn a_drag_from_an_anchor_sizes_the_corner_and_stops_at_what_fits() { + let (d, id) = doc_with_square(); + let (sel, p) = (Selection::default(), paint()); + let cx = cx(&d, &sel, &p); + let mut t = CornerTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 300.0, 100.0)); + let drag = t.pointer(&cx, &ev(PointerKind::Drag, 280.0, 120.0)); + assert_eq!(drag.first(), Some(&Action::Begin(LABEL.into()))); + assert!((radius_of(drag.get(1)) - 20.0).abs() < 1e-9, "{drag:?}"); + let Some(Action::Preview(cmd, params)) = drag.get(1) else { panic!() }; + assert_eq!((cmd.as_str(), params["items"][0]["id"].clone(), params["kind"].clone()), (SET, json!(id.0), json!("round"))); + // Shift: whole values. + let shifted = t.pointer(&cx, &with(ev(PointerKind::Drag, 276.6, 123.4), |m| m.shift = true)); + assert_eq!(radius_of(shifted.first()), 23.0); + // Past what fits: as far as the sharp corners next to it, marked at the limit. + let far = t.pointer(&cx, &ev(PointerKind::Drag, 0.0, 400.0)); + assert!((radius_of(far.first()) - 200.0).abs() < 1e-9, "{far:?}"); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::CurvePoint { color, .. } if *color == HIGHLIGHT_RED))); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text.contains("Radius: 200")))); + let up = t.pointer(&cx, &ev(PointerKind::Up, 280.0, 120.0)); + assert!(matches!(up.as_slice(), [Action::Preview(..), Action::Commit]), "{up:?}"); + // Back on the anchor: nothing to keep. Escape cancels. + t.pointer(&cx, &ev(PointerKind::Down, 300.0, 100.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 280.0, 120.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 100.0)), vec![Action::Cancel]); + t.pointer(&cx, &ev(PointerKind::Down, 300.0, 100.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 280.0, 120.0)); + assert!(t.claims_key(&cx, ToolKey::Escape)); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert!(!t.busy()); +} + +#[test] +fn a_cut_corners_marker_selects_loads_resizes_and_removes() { + let (d, id) = doc_with_corner(); + let (sel, p) = (Selection::default(), paint()); + let cx = cx(&d, &sel, &p); + let mut t = CornerTool::default(); + assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::CurvePoint { filled: false, .. }]), "the marker shows"); + t.pointer(&cx, &ev(PointerKind::Move, 281.0, 119.0)); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::CurvePoint { filled: true, .. })), "hovered"); + // A click selects the corner's anchors and loads its values. + t.pointer(&cx, &ev(PointerKind::Down, 280.0, 120.0)); + let up = t.pointer(&cx, &ev(PointerKind::Up, 280.0, 120.0)); + let Some(Action::Exec(cmd, params)) = up.first() else { panic!("{up:?}") }; + assert_eq!((cmd.as_str(), params["id"].clone(), params["mode"].clone()), ("select.anchors", json!(id.0), json!("set"))); + assert_eq!(params["anchors"], json!([[0, 1], [0, 2]])); + assert_eq!(t.options(), json!({"radius": 20.0, "kind": "chamfer"})); + // A drag resizes it, its kind kept; to nothing it goes. + t.pointer(&cx, &ev(PointerKind::Down, 280.0, 120.0)); + let drag = t.pointer(&cx, &ev(PointerKind::Drag, 270.0, 130.0)); + assert!((radius_of(drag.get(1)) - 30.0).abs() < 1e-9, "{drag:?}"); + assert!(matches!(drag.get(1), Some(Action::Preview(_, p)) if p["kind"] == json!("chamfer"))); + let gone = t.pointer(&cx, &ev(PointerKind::Up, 310.0, 90.0)); + assert!(matches!(gone.as_slice(), [Action::Preview(c, _), Action::Commit] if c == REMOVE), "{gone:?}"); + // A double-click removes it. + let dbl = t.pointer(&cx, &ev(PointerKind::DoubleClick, 280.0, 120.0)); + assert_eq!(dbl, vec![Action::Exec(REMOVE.into(), json!({"items": [{"id": id.0, "anchors": [1]}]}))]); + assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 300.0, 300.0)).is_empty(), "a sharp corner has nothing to remove"); +} + +#[test] +fn an_alt_marquee_cuts_every_corner_inside_and_a_plain_one_selects() { + let (d, id) = doc_with_corner(); + let (sel, p) = (Selection::default(), paint()); + let cx = cx(&d, &sel, &p); + let mut t = CornerTool::default(); + // Alt over an anchor starts a marquee too; the chamfered corner counts, the far side doesn't. + let alt = |e| with(e, |m| m.alt = true); + t.pointer(&cx, &alt(ev(PointerKind::Down, 50.0, 50.0))); + t.pointer(&cx, &alt(ev(PointerKind::Drag, 350.0, 200.0))); + assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::Marquee(_)])); + let up = t.pointer(&cx, &alt(ev(PointerKind::Up, 350.0, 200.0))); + assert_eq!(up, vec![Action::Exec(SET.into(), json!({"items": [{"id": id.0, "anchors": [0, 1]}], "radius": RADIUS, "kind": "round"}))]); + t.pointer(&cx, &alt(ev(PointerKind::Down, 400.0, 400.0))); + assert!(t.pointer(&cx, &alt(ev(PointerKind::Up, 450.0, 450.0))).is_empty(), "nothing inside"); + // Without Alt the corners inside are selected; a click away deselects. + t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0)); + let up = t.pointer(&cx, &ev(PointerKind::Up, 200.0, 200.0)); + let Some(Action::Exec(cmd, params)) = up.first() else { panic!("{up:?}") }; + assert_eq!((cmd.as_str(), params["anchors"].clone(), params["mode"].clone()), ("select.anchors", json!([[0, 0]]), json!("set"))); + t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 400.0, 400.0)), vec![Action::Exec("select.none".into(), json!({}))]); +} + +#[test] +fn locked_paths_are_left_alone_and_options_are_checked() { + let (mut d, id) = doc_with_corner(); + d.node_mut(id).unwrap().locked = true; + let (sel, p) = (Selection::default(), paint()); + let cx = cx(&d, &sel, &p); + let mut t = CornerTool::default(); + assert!(t.overlays(&cx).is_empty()); + t.pointer(&cx, &ev(PointerKind::Move, 100.0, 100.0)); + assert!(t.overlays(&cx).is_empty()); + for bad in [json!(-1), json!(1e308), json!("x")] { + t.set_option("radius", &bad); + } + t.set_option("kind", &json!("spiky")); + assert_eq!(t.options(), json!({"radius": RADIUS, "kind": "round"})); + assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::CornerTool); +} diff --git a/crates/tools/src/addons/shared_edge.rs b/crates/tools/src/addons/shared_edge.rs new file mode 100644 index 000000000..d11261800 --- /dev/null +++ b/crates/tools/src/addons/shared_edge.rs @@ -0,0 +1,349 @@ +//! Shared Edge Merge Tool (Path Precision Tools): where two paths, selected or not, touch at two +//! coincident anchors, hovering with the round brush shows the two stretches between them (dashed) +//! and the edge merging them (solid, green when they are merged already), and a click replaces both +//! with that edge, half way between them (`addon.pathtools.mergeEdge`, one undo step). Shift follows +//! the upper path, Alt the lower one. Where the anchors aren't there yet, a click makes one end: +//! near anchors meet half way, and an anchor one path lacks is added to match. Shift+Alt asks for +//! an end inside a stretch that could merge. + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; +use vectorcraft_geom::{BezPath, Point}; +use vectorcraft_pathops::addons::pathtools::{Follow, MergePlan, MergePreview, ends_at, merge_preview, stretches_at}; + +use crate::builder::HIGHLIGHT_RED; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +pub use vectorcraft_pathops::addons::pathtools::{COINCIDENCE, MAX_COINCIDENCE}; + +/// The tool's id. +pub const ID: &str = "sharedEdge"; +/// The brush's diameter by default and its range (pt). +pub const DIAMETER: f64 = 20.0; +pub const DIAMETER_RANGE: (f64, f64) = (0.25, 200.0); +/// Most paths under the brush compared with one another. +const MAX_PATHS: usize = 6; +const BLUE: [u8; 3] = [0x4f, 0x9d, 0xff]; +const GREEN: [u8; 3] = [0x2e, 0xb8, 0x6a]; +const GREY: [u8; 3] = [0x80, 0x80, 0x80]; +const EDGE_PX: f32 = 2.0; +const ANCHOR_PX: f32 = 7.0; + +/// What the brush is over. +#[derive(Clone, Debug, PartialEq)] +struct Target { + /// The upper path. + a: NodeId, + /// The lower path. + b: NodeId, + at: Point, + follow: Follow, + /// Shift+Alt: an end, even where the stretch could merge. + ends: bool, + preview: MergePreview, + /// The document it was found in. + revision: (u64, u64), +} + +pub struct SharedEdgeTool { + diameter: f64, + tolerance: f64, + together: bool, + at: Option, + target: Option, + pressed: bool, +} + +impl Default for SharedEdgeTool { + fn default() -> Self { + Self { diameter: DIAMETER, tolerance: COINCIDENCE, together: false, at: None, target: None, pressed: false } + } +} + +/// The editable paths the brush touches, nearest first: (paint order, id). +fn paths_under(cx: &ToolContext, at: Point, radius: f64) -> Vec<(usize, NodeId)> { + let editable = cx.doc.editable(); + let mut order = 0; + let mut out: Vec<(f64, usize, NodeId)> = vec![]; + cx.doc.walk(|n| { + order += 1; + let NodeKind::Path { path, guide: false, .. } = &n.kind else { return }; + if path.control_bounds().is_none_or(|r| !r.inflate(radius, radius).contains(at)) { + return; + } + let d = path.nearest(at).map_or(f64::INFINITY, |(.., q, _)| q.distance(at)); + if d <= radius && editable(n.id) { + out.push((d, order, n.id)); + } + }); + out.sort_by(|x, y| x.0.total_cmp(&y.0)); + out.truncate(MAX_PATHS); + out.into_iter().map(|(_, order, id)| (order, id)).collect() +} + +impl SharedEdgeTool { + /// What a click at `at` with `mods` would do, on the nearest paths that it can. + fn look(&self, cx: &ToolContext, at: Point, mods: Mods) -> Option { + let radius = self.diameter / 2.0; + let path = |id: NodeId| cx.doc.node(id).and_then(|n| n.path_data()); + let under = paths_under(cx, at, radius); + let mut pairs: Vec<(NodeId, NodeId)> = vec![]; + for (i, x) in under.iter().enumerate() { + pairs.extend(under.iter().skip(i + 1).map(|y| if x.0 > y.0 { (x.1, y.1) } else { (y.1, x.1) })); + } + let find = |f: &dyn Fn(NodeId, NodeId) -> Option| pairs.iter().find_map(|&(a, b)| Some((a, b, f(a, b)?))); + let ends = mods.shift && mods.alt; + let edge = if ends { None } else { find(&|a, b| stretches_at(path(a)?, path(b)?, at, radius, self.tolerance)) }; + let (a, b, plan) = edge.or_else(|| find(&|a, b| ends_at(path(a)?, path(b)?, at, radius)))?; + let follow = match (mods.shift, mods.alt) { + (true, false) => Follow::First, + (false, true) => Follow::Second, + _ => Follow::Average, + }; + let preview = merge_preview(path(a)?, path(b)?, &plan, follow)?; + Some(Target { a, b, at, follow, ends, preview, revision: cx.revision }) + } + + fn params(&self, t: &Target) -> Value { + json!({ + "a": t.a.0, + "b": t.b.0, + "at": [t.at.x, t.at.y], + "diameter": self.diameter, + "tolerance": self.tolerance, + "follow": t.follow.id(), + "ends": t.ends, + }) + } +} + +fn outline(chain: &[kurbo::CubicBez]) -> BezPath { + let mut path = BezPath::new(); + if let Some(c) = chain.first() { + path.move_to(c.p0); + } + for c in chain { + path.curve_to(c.p1, c.p2, c.p3); + } + path +} + +impl Tool for SharedEdgeTool { + fn id(&self) -> &'static str { + ID + } + + fn busy(&self) -> bool { + self.pressed + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + self.at = Some(ev.pos); + self.target = self.look(cx, ev.pos, ev.mods); + match ev.kind { + PointerKind::Down => self.pressed = true, + PointerKind::Up if std::mem::take(&mut self.pressed) => { + let target = self.target.take(); + let todo = target.filter(|t| !matches!(t.preview, MergePreview::Edge { done: true, .. })); + return todo.map_or_else(Vec::new, |t| vec![Action::Exec("addon.pathtools.mergeEdge".into(), self.params(&t))]); + } + _ => {} + } + vec![] + } + + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key == ToolKey::Escape { + self.pressed = false; + } + vec![] + } + + fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { + self.pressed && key == ToolKey::Escape + } + + fn hover_ended(&mut self, _cx: &ToolContext) { + self.at = None; + self.target = None; + } + + fn overlays(&self, cx: &ToolContext) -> Vec { + let Some(at) = self.at else { return vec![] }; + let r = self.diameter / 2.0; + let ring = kurbo::Shape::to_path(&kurbo::Circle::new(at, r), 0.1 * r); + let mut out = vec![Overlay::Path { path: ring, color: GREY, width: 1.0, dashed: false }]; + let curve = |chain: &[kurbo::CubicBez], color, dashed| Overlay::Path { path: outline(chain), color, width: EDGE_PX, dashed }; + let anchor = |p, color, filled| Overlay::Anchor { p, color, filled, size: ANCHOR_PX }; + match self.target.as_ref().filter(|t| t.revision == cx.revision).map(|t| &t.preview) { + Some(MergePreview::Edge { merged, done: true, .. }) => out.push(curve(merged, GREEN, false)), + Some(MergePreview::Edge { a, b, merged, .. }) => { + out.extend([curve(a, BLUE, true), curve(b, BLUE, true), curve(merged, HIGHLIGHT_RED, false)]) + } + Some(MergePreview::Ends { a, b, to }) => out.extend([anchor(*a, BLUE, false), anchor(*b, BLUE, false), anchor(*to, HIGHLIGHT_RED, true)]), + None => {} + } + out + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { + Cursor::SharedEdge + } + + fn options(&self) -> Value { + json!({ "diameter": self.diameter, "tolerance": self.tolerance, "together": self.together }) + } + + fn set_option(&mut self, key: &str, v: &Value) { + match key { + "diameter" => { + if let Some(d) = v.as_f64().filter(|d| (DIAMETER_RANGE.0..=DIAMETER_RANGE.1).contains(d)) { + self.diameter = d; + } + } + "tolerance" => { + if let Some(t) = v.as_f64().filter(|t| (0.0..=MAX_COINCIDENCE).contains(t)) { + self.tolerance = t; + } + } + "together" => { + if let Some(on) = v.as_bool() { + self.together = on; + } + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use vectorcraft_doc::{Appearance, Document, Node, Selection}; + use vectorcraft_geom::{PathData, SubPath}; + + /// A lower and an upper square sharing the edge x = 100 from y = 0 to y = 100; `gap` pushes the + /// upper one right. → (the document, the lower one, the upper one). + fn doc_with_squares(gap: f64) -> (Document, NodeId, NodeId) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (lower, upper) = (d.alloc_id(), d.alloc_id()); + let square = |x: f64| [(x, 0.0), (x + 100.0, 0.0), (x + 100.0, 100.0), (x, 100.0)].map(|(x, y)| Point::new(x, y)); + for (i, (id, x)) in [(lower, 0.0), (upper, 100.0 + gap)].into_iter().enumerate() { + d.insert(Some(l), i, Node::path(id, PathData::single(SubPath::polyline(&square(x), true)), Appearance::default_art())).unwrap(); + } + (d, lower, upper) + } + + fn ev(kind: PointerKind, x: f64, y: f64, shift: bool, alt: bool) -> PointerEvent { + let mut e = PointerEvent::new(kind, x, y); + (e.mods.shift, e.mods.alt) = (shift, alt); + e + } + + /// The params of a click at (x, y). + fn click(t: &mut SharedEdgeTool, cx: &ToolContext, x: f64, y: f64, shift: bool, alt: bool) -> Option { + t.pointer(cx, &ev(PointerKind::Down, x, y, shift, alt)); + match t.pointer(cx, &ev(PointerKind::Up, x, y, shift, alt)).as_slice() { + [Action::Exec(cmd, p)] => { + assert_eq!(cmd, "addon.pathtools.mergeEdge"); + Some(p.clone()) + } + [] => None, + other => panic!("{other:?}"), + } + } + + #[test] + fn an_edge_merged_already_shows_green_and_a_click_leaves_it() { + let (d, ..) = doc_with_squares(0.0); + let (sel, p) = (Selection::default(), paint()); + let cx = cx(&d, &sel, &p); + let mut t = SharedEdgeTool::default(); + assert!(t.pointer(&cx, &ev(PointerKind::Move, 100.0, 50.0, false, false)).is_empty()); + // They are one edge already: it shows green and a click has nothing to do. + let shows = t.overlays(&cx); + assert!(matches!(shows.as_slice(), [Overlay::Path { color: GREY, .. }, Overlay::Path { color: GREEN, dashed: false, .. }]), "{shows:?}"); + assert_eq!(click(&mut t, &cx, 100.0, 50.0, false, false), None); + assert!(!t.busy()); + assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::SharedEdge); + } + + #[test] + fn a_click_averages_the_stretches_and_shift_and_alt_follow_a_path() { + // The stretches differ, their ends coincident within the tolerance set. + let (d, lower, upper) = doc_with_squares(1.0); + let (sel, p) = (Selection::default(), paint()); + let cx = cx(&d, &sel, &p); + let mut t = SharedEdgeTool::default(); + t.set_option("tolerance", &json!(3)); + t.pointer(&cx, &ev(PointerKind::Move, 100.5, 50.0, false, false)); + let shows = t.overlays(&cx); + assert!( + matches!( + shows.as_slice(), + [ + _, + Overlay::Path { color: BLUE, dashed: true, .. }, + Overlay::Path { color: BLUE, dashed: true, .. }, + Overlay::Path { dashed: false, .. } + ] + ), + "{shows:?}" + ); + let plain = click(&mut t, &cx, 100.5, 50.0, false, false).expect("a merge"); + assert_eq!((plain["a"].clone(), plain["b"].clone(), plain["follow"].clone()), (json!(upper.0), json!(lower.0), json!("average"))); + assert_eq!((plain["at"].clone(), plain["ends"].clone(), plain["tolerance"].clone()), (json!([100.5, 50.0]), json!(false), json!(3.0))); + assert_eq!(click(&mut t, &cx, 100.5, 50.0, true, false).expect("a merge")["follow"], json!("a")); + assert_eq!(click(&mut t, &cx, 100.5, 50.0, false, true).expect("a merge")["follow"], json!("b")); + // Shift+Alt: an end, inside the stretch. + let end = click(&mut t, &cx, 100.5, 2.0, true, true).expect("an end"); + assert_eq!((end["ends"].clone(), end["follow"].clone()), (json!(true), json!("average"))); + } + + #[test] + fn near_anchors_make_an_end_and_nothing_near_does_nothing() { + let (d, ..) = doc_with_squares(5.0); + let (sel, p) = (Selection::default(), paint()); + let cx = cx(&d, &sel, &p); + let mut t = SharedEdgeTool::default(); + t.pointer(&cx, &ev(PointerKind::Move, 102.0, 1.0, false, false)); + let shows = t.overlays(&cx); + assert!(matches!(shows.as_slice(), [_, Overlay::Anchor { .. }, Overlay::Anchor { .. }, Overlay::Anchor { filled: true, .. }]), "{shows:?}"); + assert_eq!(click(&mut t, &cx, 102.0, 1.0, false, false).expect("an end")["ends"], json!(false)); + // One path alone, and empty canvas. + assert_eq!(click(&mut t, &cx, 0.0, 50.0, false, false), None); + assert_eq!(click(&mut t, &cx, 300.0, 300.0, false, false), None); + } + + #[test] + fn locked_paths_are_left_alone_and_escape_drops_a_press() { + let (mut d, _, upper) = doc_with_squares(5.0); + d.node_mut(upper).unwrap().locked = true; + let (sel, p) = (Selection::default(), paint()); + let cx = cx(&d, &sel, &p); + let mut t = SharedEdgeTool::default(); + assert_eq!(click(&mut t, &cx, 102.0, 1.0, false, false), None); + t.pointer(&cx, &ev(PointerKind::Down, 102.0, 1.0, false, false)); + assert!(t.busy() && t.claims_key(&cx, ToolKey::Escape)); + assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty()); + assert!(!t.busy()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 102.0, 1.0, false, false)).is_empty()); + } + + #[test] + fn options_stay_in_range() { + let mut t = SharedEdgeTool::default(); + for (k, v) in + [("diameter", json!(1000)), ("diameter", json!(0.1)), ("tolerance", json!(4)), ("tolerance", json!("x")), ("together", json!(1))] + { + t.set_option(k, &v); + } + assert_eq!(t.options(), json!({ "diameter": DIAMETER, "tolerance": COINCIDENCE, "together": false })); + t.set_option("diameter", &json!(40)); + t.set_option("tolerance", &json!(0)); + t.set_option("together", &json!(true)); + assert_eq!(t.options(), json!({ "diameter": 40.0, "tolerance": 0.0, "together": true })); + } +} diff --git a/crates/tools/src/addons/slide_anchor.rs b/crates/tools/src/addons/slide_anchor.rs new file mode 100644 index 000000000..e24db4c50 --- /dev/null +++ b/crates/tools/src/addons/slide_anchor.rs @@ -0,0 +1,231 @@ +//! Slide Anchor Along Path (Path Precision Tools): drag an anchor, selected or not, and it runs +//! along the curve it sits on, the two segments beside it refitted so the outline stays +//! (`addon.pathtools.slideAnchor`, previewed while dragging, one undo step). Shift snaps to where +//! the path's tangent is at a multiple of 45° from the constrain angle. Ends of open paths don't +//! slide. + +use serde_json::json; +use vectorcraft_doc::{NodeId, NodeKind}; +use vectorcraft_geom::{Point, SubPath}; +use vectorcraft_pathops::addons::pathtools::{slide_stops, slide_t_at}; + +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +/// The tool's id. +pub const ID: &str = "slideAnchor"; +/// The undo step's name. +const LABEL: &str = "Slide Anchor Along Path"; +const BLUE: [u8; 3] = [0x4f, 0x9d, 0xff]; +const SIZE: f32 = 7.0; + +/// An anchor that can slide. +#[derive(Clone, Debug, PartialEq)] +struct Grip { + id: NodeId, + subpath: usize, + anchor: usize, + p: Point, + /// The subpath as it was. + sp: SubPath, +} + +#[derive(Default)] +pub struct SlideAnchorTool { + hover: Option, + /// The anchor being dragged and how far along. + drag: Option<(Grip, f64)>, +} + +/// The anchor of an editable path nearest `p` within the point tolerance that can slide. +fn grip_at(cx: &ToolContext, p: Point) -> Option { + let (tol, editable) = (cx.point_tol(), cx.doc.editable()); + let mut best: Option<(f64, NodeId, usize, usize, Point)> = None; + cx.doc.walk(|n| { + let NodeKind::Path { path, guide: false, .. } = &n.kind else { return }; + for (si, ai, a) in path.anchors() { + let d = a.p.distance(p); + let open_end = path.subpaths.get(si).is_some_and(|sp| !sp.closed && (ai == 0 || ai + 1 == sp.anchors.len())); + if d <= tol && !open_end && best.is_none_or(|b| d < b.0) && editable(n.id) { + best = Some((d, n.id, si, ai, a.p)); + } + } + }); + let (_, id, subpath, anchor, at) = best?; + let sp = cx.doc.node(id)?.path_data()?.subpaths.get(subpath)?.clone(); + Some(Grip { id, subpath, anchor, p: at, sp }) +} + +/// How far along a drag to `p` takes `g`: with Shift, to the nearest place where the tangent is at +/// a multiple of 45° from the constrain angle. +fn slide_to(cx: &ToolContext, g: &Grip, p: Point, mods: Mods) -> f64 { + let t = slide_t_at(&g.sp, g.anchor, p).unwrap_or(0.0); + let stops = if mods.shift { slide_stops(&g.sp, g.anchor, cx.constrain_angle) } else { vec![] }; + stops.into_iter().min_by(|a, b| (a - t).abs().total_cmp(&(b - t).abs())).unwrap_or(t) +} + +impl Tool for SlideAnchorTool { + fn id(&self) -> &'static str { + ID + } + + fn busy(&self) -> bool { + self.drag.is_some() + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + match ev.kind { + PointerKind::Move => { + self.hover = grip_at(cx, ev.pos); + vec![] + } + PointerKind::Down => { + let Some(g) = grip_at(cx, ev.pos) else { return vec![] }; + self.hover = None; + self.drag = Some((g, 0.0)); + vec![Action::Begin(LABEL.into())] + } + PointerKind::Drag | PointerKind::Up => { + let Some((g, _)) = self.drag.take() else { return vec![] }; + let t = slide_to(cx, &g, ev.pos, ev.mods); + let params = json!({ "id": g.id.0, "subpath": g.subpath, "anchor": g.anchor, "t": t }); + let preview = Action::Preview("addon.pathtools.slideAnchor".into(), params); + if ev.kind == PointerKind::Drag { + self.drag = Some((g, t)); + return vec![preview]; + } + // Back where it started: nothing to keep. + if t == 0.0 { vec![Action::Cancel] } else { vec![preview, Action::Commit] } + } + PointerKind::DoubleClick => vec![], + } + } + + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key == ToolKey::Escape && self.drag.take().is_some() { vec![Action::Cancel] } else { vec![] } + } + + fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { + self.drag.is_some() && key == ToolKey::Escape + } + + fn hover_ended(&mut self, _cx: &ToolContext) { + self.hover = None; + } + + fn overlays(&self, _cx: &ToolContext) -> Vec { + let anchor = |p, filled| Overlay::Anchor { p, color: BLUE, filled, size: SIZE }; + match (&self.drag, &self.hover) { + (Some((g, _)), _) => vec![anchor(g.p, false)], + (None, Some(g)) => vec![anchor(g.p, true)], + (None, None) => vec![], + } + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { + Cursor::SlideAnchor + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use vectorcraft_doc::{Appearance, Document, Node, Selection}; + use vectorcraft_geom::PathData; + + /// A document with an open zigzag (100, 100), (200, 100), (300, 100) and a circle-ish curve. + fn doc_with_line() -> (Document, NodeId) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let pts = [Point::new(100.0, 100.0), Point::new(200.0, 100.0), Point::new(300.0, 100.0)]; + d.insert(Some(l), 0, Node::path(id, PathData::single(SubPath::polyline(&pts, false)), Appearance::default_art())).unwrap(); + (d, id) + } + + fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent::new(kind, x, y) + } + + #[test] + fn dragging_an_anchor_previews_the_slide_and_commits() { + let (d, id) = doc_with_line(); + let (sel, p) = (Selection::default(), paint()); + let cx = cx(&d, &sel, &p); + let mut t = SlideAnchorTool::default(); + assert!(t.pointer(&cx, &ev(PointerKind::Move, 201.0, 101.0)).is_empty()); + assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::Anchor { filled: true, .. }])); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 201.0, 101.0)), vec![Action::Begin(LABEL.into())]); + assert!(t.busy()); + let drag = t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 130.0)); + let Some(Action::Preview(cmd, params)) = drag.first() else { panic!("{drag:?}") }; + assert_eq!(cmd, "addon.pathtools.slideAnchor"); + assert_eq!((params["id"].clone(), params["subpath"].clone(), params["anchor"].clone()), (json!(id.0), json!(0), json!(1))); + assert!((params["t"].as_f64().unwrap() - 0.5).abs() < 1e-6); + // The other way along the path. + let up = t.pointer(&cx, &ev(PointerKind::Up, 175.0, 90.0)); + assert!(matches!(up.as_slice(), [Action::Preview(_, p), Action::Commit] if p["t"].as_f64().unwrap() < 0.0), "{up:?}"); + assert!(!t.busy()); + } + + #[test] + fn back_where_it_started_nothing_is_kept_and_escape_cancels() { + let (d, _) = doc_with_line(); + let (sel, p) = (Selection::default(), paint()); + let cx = cx(&d, &sel, &p); + let mut t = SlideAnchorTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 200.0, 100.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 200.0, 100.0)), vec![Action::Cancel]); + t.pointer(&cx, &ev(PointerKind::Down, 200.0, 100.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 100.0)); + assert!(t.claims_key(&cx, ToolKey::Escape)); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert!(!t.busy() && !t.claims_key(&cx, ToolKey::Escape)); + } + + #[test] + fn ends_and_empty_canvas_do_nothing() { + let (d, _) = doc_with_line(); + let (sel, p) = (Selection::default(), paint()); + let cx = cx(&d, &sel, &p); + let mut t = SlideAnchorTool::default(); + for (x, y) in [(100.0, 100.0), (300.0, 100.0), (150.0, 300.0)] { + assert!(t.pointer(&cx, &ev(PointerKind::Down, x, y)).is_empty(), "({x}, {y})"); + assert!(t.pointer(&cx, &ev(PointerKind::Up, x, y)).is_empty()); + } + assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::SlideAnchor); + } + + #[test] + fn shift_snaps_to_where_the_tangent_is_at_45_degrees() { + // A smooth anchor on a curve turning from east to south through south-east. + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let a = |x: f64, y: f64, h: (f64, f64), o: (f64, f64)| vectorcraft_geom::Anchor { + p: Point::new(x, y), + h_in: Point::new(h.0, h.1), + h_out: Point::new(o.0, o.1), + kind: vectorcraft_geom::AnchorKind::Smooth, + }; + let sp = SubPath::new( + vec![ + a(0.0, 100.0, (0.0, 100.0), (0.0, 100.0)), + a(100.0, 100.0, (100.0, 100.0), (155.0, 100.0)), + a(200.0, 200.0, (200.0, 145.0), (200.0, 200.0)), + ], + false, + ); + d.insert(Some(l), 0, Node::path(id, PathData::single(sp.clone()), Appearance::default_art())).unwrap(); + let (sel, p) = (Selection::default(), paint()); + let cx = cx(&d, &sel, &p); + let mut t = SlideAnchorTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0)); + let mut shifted = ev(PointerKind::Drag, 140.0, 105.0); + shifted.mods.shift = true; + let drag = t.pointer(&cx, &shifted); + let Some(Action::Preview(_, params)) = drag.first() else { panic!("{drag:?}") }; + let at = params["t"].as_f64().unwrap(); + assert!((at - 0.5).abs() < 1e-6, "half way round a symmetric quarter turn: {at}"); + } +} diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index 607584bb9..7265122c3 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -87,13 +87,14 @@ fn lp_overlays(h: &LpHover, cache: &RegionCache, color: [u8; 3], width: f32) -> } /// Live Paint Bucket: click fills the face under the cursor with the current fill; Shift-click -/// paints the nearest edge with the current stroke. Clicking selected ordinary paths makes them a -/// Live Paint group first. +/// paints the nearest edge with the current stroke. Alt-click samples a fill or stroke into the +/// paint defaults; Alt+Shift-click samples only the color under the pointer. Clicking selected +/// ordinary paths without Alt makes them a Live Paint group first. pub struct LivePaintBucketTool { pub(super) cache: RegionCache, pub(super) hover: LpHover, - /// Where the pointer last moved, and whether Shift was held. - at: Option<(Point, bool)>, + /// Where the pointer last moved, and whether Shift and Alt were held. + at: Option<(Point, bool, bool)>, } impl Default for LivePaintBucketTool { @@ -111,12 +112,35 @@ impl Tool for LivePaintBucketTool { let p = ev.pos; match ev.kind { PointerKind::Move => { - self.at = Some((p, ev.mods.shift)); - self.hover = lp_hover(cx, Some(&mut self.cache), p, ev.mods.shift, &|id| lp_visible(cx, id)); + self.at = Some((p, ev.mods.shift, ev.mods.alt)); + self.hover = + if ev.mods.alt { LpHover::None } else { lp_hover(cx, Some(&mut self.cache), p, ev.mods.shift, &|id| lp_visible(cx, id)) }; vec![] } PointerKind::Down => { self.hover = LpHover::None; + if ev.mods.alt { + if ev.mods.shift { + return crate::xform::eyedropper::sample_color_at(cx, p).map_or_else(Vec::new, |color| { + vec![Action::Exec("paint.sampleColor".into(), json!({ "color": crate::params::color_json(&color), "ids": [] }))] + }); + } + return crate::xform::eyedropper::sample_source(cx, p).map_or_else(Vec::new, |(source, _)| { + if cx.doc.node(source).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))) { + return crate::xform::eyedropper::sample_color_at(cx, p).map_or_else(Vec::new, |color| { + vec![Action::Exec( + "paint.sampleColor".into(), + json!({ "color": crate::params::color_json(&color), "stroke": false, "ids": [], "clearStroke": true }), + )] + }); + } + if crate::xform::eyedropper::has_sampleable_paint(cx, source) { + vec![Action::Exec("appearance.copyFrom".into(), json!({ "source": source.0, "defaultsOnly": true }))] + } else { + vec![] + } + }); + } let point = json!([p.x, p.y]); if ev.mods.shift { return match lp_part_at(cx.doc, p, cx.tol(4.0), true, &|id| lp_visible(cx, id)) { @@ -143,8 +167,8 @@ impl Tool for LivePaintBucketTool { lp_overlays(&self.hover, &self.cache, HIGHLIGHT_RED, 4.0) } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::LivePaintBucket + fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor { + if m.alt { Cursor::Eyedropper } else { Cursor::LivePaintBucket } } fn waiting(&self) -> bool { @@ -159,8 +183,8 @@ impl Tool for LivePaintBucketTool { // of a Live Paint group the hover takes no map. loop { self.cache.settle(wait); - if let Some((p, shift)) = self.at { - self.hover = lp_hover(cx, Some(&mut self.cache), p, shift, &|id| lp_visible(cx, id)); + if let Some((p, shift, alt)) = self.at { + self.hover = if alt { LpHover::None } else { lp_hover(cx, Some(&mut self.cache), p, shift, &|id| lp_visible(cx, id)) }; } if !wait || !self.cache.waiting() { break; diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index aeb753b4c..f4a8b771f 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -765,6 +765,125 @@ fn bucket_click_fills_face_and_shift_click_strokes_edge() { assert_eq!(id, "livePaint.strokeEdge"); } +#[test] +fn bucket_alt_samples_face_edge_and_selected_region_without_painting() { + let (mut d, _gid, face, edge) = live_group_doc(); + d.node_mut(face).unwrap().appearance.set_fill(vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE)); + d.node_mut(edge).unwrap().appearance.set_stroke(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK)); + let s = Selection::default(); + let p = paint(); + let cx = crate::testutil::cx(&d, &s, &p); + let mut t = LivePaintBucketTool::default(); + let alt = Mods { alt: true, ..Default::default() }; + assert_eq!( + t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0).with_mods(alt)), + vec![Action::Exec("appearance.copyFrom".into(), json!({"source": face.0, "defaultsOnly": true}))] + ); + assert_eq!( + t.pointer(&cx, &ev(PointerKind::Down, 50.0, 25.0).with_mods(alt)), + vec![Action::Exec("appearance.copyFrom".into(), json!({"source": edge.0, "defaultsOnly": true}))] + ); + + let (d, a, b) = two_rects(); + let mut s = Selection::default(); + s.set([a, b]); + let cx = crate::testutil::cx(&d, &s, &p); + assert_eq!( + t.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0).with_mods(alt)), + vec![Action::Exec("appearance.copyFrom".into(), json!({"source": b.0, "defaultsOnly": true}))] + ); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0).with_mods(alt)).is_empty()); + let (d, _, _, _) = live_group_doc(); + assert!(t.pointer(&crate::testutil::cx(&d, &Selection::default(), &p), &ev(PointerKind::Down, 75.0, 25.0).with_mods(alt)).is_empty()); +} + +#[test] +fn bucket_alt_shift_samples_only_solid_color_and_restores_hover() { + let (mut d, _gid, face, _edge) = live_group_doc(); + d.node_mut(face).unwrap().appearance.set_fill(vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE)); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = LivePaintBucketTool::default(); + let alt = Mods { alt: true, ..Default::default() }; + let both = Mods { alt: true, shift: true, ..Default::default() }; + let pos = Point::new(75.0, 25.0); + assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucket); + t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); + assert!(!t.overlays(&cx).is_empty()); + t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0).with_mods(alt)); + assert_eq!(t.cursor(&cx, pos, alt), Cursor::Eyedropper); + assert_eq!(t.id(), "livePaintBucket"); + assert!(t.overlays(&cx).is_empty()); + assert_eq!( + t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0).with_mods(both)), + vec![Action::Exec("paint.sampleColor".into(), json!({"color": crate::params::color_json(&vectorcraft_color::Color::WHITE), "ids": []}))] + ); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0).with_mods(both)).is_empty()); + t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); + assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucket); + assert!(!t.overlays(&cx).is_empty()); +} + +#[test] +fn bucket_alt_shift_samples_gradient_image_and_stroke_color() { + let (mut d, _a, b) = two_rects(); + let gradient = vectorcraft_color::Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); + d.node_mut(b).unwrap().appearance.set_fill(gradient.clone()); + let s = Selection::default(); + let p = paint(); + let both = Mods { alt: true, shift: true, ..Default::default() }; + let mut t = LivePaintBucketTool::default(); + assert_eq!( + t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 100.0, 50.0).with_mods(both)), + vec![Action::Exec( + "paint.sampleColor".into(), + json!({"color": crate::params::color_json(&vectorcraft_color::Color::rgb(0.5, 0.5, 0.5)), "ids": []}) + )] + ); + + let (mut d, _gid, _face, edge) = live_group_doc(); + d.node_mut(edge).unwrap().appearance.set_stroke(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK)); + assert_eq!( + t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 50.0, 25.0).with_mods(both)), + vec![Action::Exec("paint.sampleColor".into(), json!({"color": crate::params::color_json(&vectorcraft_color::Color::BLACK), "ids": []}))] + ); + + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let img = image::RgbaImage::from_raw(3, 1, [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255]].concat()).unwrap(); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + d.images.insert("px".into(), vectorcraft_doc::ImageBlob::png(png)); + let im = vectorcraft_doc::ImageObject { + key: "px".into(), + width: 3, + height: 1, + xf: vectorcraft_geom::Affine::scale(10.0), + link: None, + placement: Default::default(), + pixels: None, + }; + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::new(id, vectorcraft_doc::NodeKind::Image(Box::new(im)))).unwrap(); + let mut cx = cx(&d, &s, &p); + cx.raster_sample = 3; + assert_eq!( + t.pointer(&cx, &ev(PointerKind::Down, 15.0, 5.0).with_mods(Mods { alt: true, ..Default::default() })), + vec![Action::Exec( + "paint.sampleColor".into(), + json!({"color": crate::params::color_json(&vectorcraft_color::Color::rgb8(85, 85, 85)), "stroke": false, "ids": [], "clearStroke": true}) + )] + ); + assert_eq!( + t.pointer(&cx, &ev(PointerKind::Down, 15.0, 5.0).with_mods(both)), + vec![Action::Exec( + "paint.sampleColor".into(), + json!({"color": crate::params::color_json(&vectorcraft_color::Color::rgb8(85, 85, 85)), "ids": []}) + )] + ); +} + #[test] fn bucket_on_plain_selection_makes_live_paint() { let (d, a, b) = two_rects(); diff --git a/crates/tools/src/cursor.rs b/crates/tools/src/cursor.rs index cba597e95..b34c32309 100644 --- a/crates/tools/src/cursor.rs +++ b/crates/tools/src/cursor.rs @@ -180,6 +180,15 @@ pub enum Cursor { /// The Parametric Shape Tool (Path Precision Tools add-on): a drag draws a shape out from its /// centre; a selected shape's handles edit it. ParametricShape, + /// The Corner Tool (Path Precision Tools add-on): a click or drag on an anchor cuts a corner; + /// a corner's marker resizes it. + CornerTool, + /// The Slide Anchor Along Path tool (Path Precision Tools add-on): a drag runs an anchor along + /// the path. + SlideAnchor, + /// The Shared Edge Merge Tool (Path Precision Tools add-on): a click merges the two paths' + /// touching edges under the brush, or makes an end there. + SharedEdge, /// The Smart Pen Tool (Smart Pen add-on) away from paths, where a click starts a new path (over /// a path or while drawing it shows the Pen's and Direct Selection's cursors). SmartPen, @@ -278,6 +287,9 @@ impl Cursor { | Cursor::ExtendPath | Cursor::MeasureInspect | Cursor::ParametricShape + | Cursor::CornerTool + | Cursor::SlideAnchor + | Cursor::SharedEdge | Cursor::SmartPen => Cursor::Crosshair, c => c, } @@ -316,7 +328,10 @@ impl Cursor { | Cursor::AnchorEraser | Cursor::ExtendPath | Cursor::MeasureInspect - | Cursor::ParametricShape => Some(Cursor::Crosshair), + | Cursor::ParametricShape + | Cursor::CornerTool + | Cursor::SlideAnchor + | Cursor::SharedEdge => Some(Cursor::Crosshair), Cursor::TypeVertical => Some(Cursor::Text), Cursor::MeshAdd | Cursor::PuppetPin => Some(Cursor::PenAdd), Cursor::SmartPen => Some(Cursor::Pen), diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 2dd2a014c..7eaefffc9 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -702,6 +702,24 @@ group = "Drawing and editing tools" shown_when = ["The Parametric Shape Tool (Path Precision Tools add-on): a drag draws a re-editable shape out from its centre; a click selects one and shows its handles."] hotspot = "Exact: the centre of the shape being drawn." +[[cursor]] +id = "CornerTool" +group = "Drawing and editing tools" +shown_when = ["The Corner Tool (Path Precision Tools add-on): a click or drag on an anchor where two sides meet cuts a corner there; a drag on a corner's marker resizes it; an Alt-drag cuts corners on every anchor inside the marquee."] +hotspot = "Exact: the anchor or marker being picked." + +[[cursor]] +id = "SlideAnchor" +group = "Drawing and editing tools" +shown_when = ["The Slide Anchor Along Path tool (Path Precision Tools add-on): a drag moves an anchor along the curve it sits on."] +hotspot = "Exact: the anchor being dragged follows it along the path." + +[[cursor]] +id = "SharedEdge" +group = "Drawing and editing tools" +shown_when = ["The Shared Edge Merge Tool (Path Precision Tools add-on): a click merges the touching edges of two paths under the round brush, or brings their anchors together there."] +hotspot = "Exact: the centre of the brush, drawn by the app as a circle around the cursor." + # ── Symbolism tools ─────────────────────────────────────────────────────────────────────────────── # The brush size is drawn by the app as a circle around the cursor. diff --git a/crates/tools/src/draw2/anchor/tests.rs b/crates/tools/src/draw2/anchor/tests.rs index f6986cf7e..ea4862c58 100644 --- a/crates/tools/src/draw2/anchor/tests.rs +++ b/crates/tools/src/draw2/anchor/tests.rs @@ -261,7 +261,7 @@ fn anchor_point_shift_constrains_a_blend_spine_handle() { let shift = Mods { shift: true, ..Mods::default() }; let mut tool = AnchorTool::new("anchorPoint"); tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 190.0, 330.0)); - let drag = PointerEvent { kind: PointerKind::Drag, pos: Point::new(200.0, 340.0), mods: shift, pressure: 1.0 }; + let drag = PointerEvent { kind: PointerKind::Drag, pos: Point::new(200.0, 340.0), mods: shift, backquote: false, pressure: 1.0 }; let actions = tool.pointer(&c, &drag); // The pointer (40, 30) keeps its length of 50; Shift snaps it to 45 degrees. assert!(matches!(actions.get(1), Some(Action::Preview(cmd, args)) diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 308264137..6036a06d9 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -14,6 +14,8 @@ //! collect the drag polyline (shown as an overlay) and run one command on release. With Shift held, the Blob Brush's points stay on the horizontal or //! vertical line through the press point. Alt held at the press, or before the Blob Brush's drag //! has moved, switches the Blob Brush to the Smooth tool: the drag smooths the selected paths. +//! Alt held at a Paintbrush press with a selected path also smooths; Alt pressed after its stroke +//! begins closes the path on release. //! Alt held at the Knife's press makes it cut along the straight line from the press point to the //! pointer, even if Alt is released during the drag. Shift on each drag or release constrains that //! line to 45° steps from the canvas axes. @@ -26,8 +28,8 @@ use super::{FEEDBACK, eraser_area, points_json, polyline}; use crate::guides::DrawSnap; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; -/// How far from its drag the Smooth tool smooths, in screen pixels. The Pencil uses the same -/// radius while Alt switches it to the Smooth tool. +/// How far from its drag the Smooth tool smooths, in screen pixels. Alt smoothing with the +/// Pencil, Paintbrush or Blob Brush uses the same radius. const SMOOTH_RADIUS: f64 = 12.0; /// The finest Fidelity (screen pixels) a Pencil or Paintbrush stroke is fitted at while it is drawn: @@ -79,7 +81,8 @@ pub struct GestureTool { /// or merges into (on for the Pencil and Paintbrush, off for the Blob Brush). pub keep_selected: bool, /// "Alt key toggles to Smooth Tool": holding Alt with the Pencil tool smooths selected paths - /// instead of closing the path (Pencil only, off by default). + /// instead of closing the path (Pencil only, off by default). Paintbrush Alt smoothing with a + /// selected path and Blob Brush Alt smoothing do not use this toggle. pub alt_smooth: bool, /// "Live preview" (Pencil only, on by default): the stroke shows with its fill and stroke as it /// is drawn. Off, only a thin line follows the pen while drawing, and the path takes its fill @@ -89,10 +92,10 @@ pub struct GestureTool { /// preview shows the points joined by straight segments and the release fits them (Pencil /// only, on by default). pub live_fit: bool, - /// The Smooth tool's Fidelity, used when Alt switches the Pencil or Blob Brush to Smooth. + /// The Smooth tool's Fidelity, used when Alt switches the Pencil, Paintbrush or Blob Brush to Smooth. /// The engine sets it from Smooth Tool Options (`smoothFidelity`). smooth_fidelity: f64, - /// Active drag is smoothing with the Pencil or the Blob Brush (Alt held). + /// Active drag is smoothing with the Pencil, Paintbrush or Blob Brush (Alt held). smoothing: bool, /// The pointer has moved from the press point, by any distance (a move too short to add a /// point counts). @@ -126,6 +129,22 @@ fn redraw_targets(cx: &ToolContext, id: NodeId) -> Vec { } } +/// Whether the selection contains a path the Smooth command can act on. +fn has_selected_smooth_path(cx: &ToolContext) -> bool { + let editable = cx.doc.editable(); + cx.selection.objects.iter().filter_map(|id| cx.doc.node(*id)).any(|n| { + let mut found = false; + n.walk(&mut |child| { + if matches!(&child.kind, NodeKind::Path { guide: false, path, .. } if path.control_bounds().is_some() && path.subpaths.iter().any(|sp| sp.anchors.len() >= 3)) + && editable(child.id) + { + found = true; + } + }); + found + }) +} + impl GestureTool { pub fn new(id: &str) -> Self { let id: &'static str = match id { @@ -402,7 +421,7 @@ impl GestureTool { // Close Paths When Ends Are Within is off at 0 pixels. let near = pts.last().is_some_and(|&end| self.ends_near_start(cx, end)); // A straight line has two points: closing it would draw it back over itself. - let closed = !self.straight && ((m.alt && !self.alt_smooth) || (self.extend.is_none() && near)); + let closed = !self.straight && ((m.alt && (self.id == "paintbrush" || !self.alt_smooth)) || (self.extend.is_none() && near)); let mut params = self.freehand_params(closed); // A Pencil stroke that continues a selected path and ends on an open end of another // path selected at the press connects the two. @@ -464,9 +483,12 @@ impl Tool for GestureTool { self.active = true; self.began = false; self.near_start = false; - // Alt switches the Pencil to the Smooth tool while Alt key toggles to Smooth Tool is on, and always - // switches the Blob Brush. - self.smoothing = ev.mods.alt && (self.id == "blobBrush" || (self.id == "pencil" && self.alt_smooth)); + // Alt switches the Pencil when its toggle is on, the Blob Brush always, and the + // Paintbrush when the press has a selected path. + self.smoothing = ev.mods.alt + && (self.id == "blobBrush" + || (self.id == "pencil" && self.alt_smooth) + || (self.id == "paintbrush" && has_selected_smooth_path(cx))); // Alt draws a straight line only while it doesn't lend the Smooth tool; with Shift // too, the line keeps Shift's 45° steps. Alt makes the Knife cut along a straight line. self.straight = (self.id == "pencil" && !self.smoothing && (ev.mods.shift || ev.mods.alt)) || (self.id == "knife" && ev.mods.alt); @@ -694,9 +716,13 @@ impl Tool for GestureTool { o } fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { - // A drag that smooths, or Alt before a Pencil or Blob Brush stroke has begun to draw (a - // stroke that has begun keeps drawing with Alt). - if self.smoothing || (self.id == "pencil" && self.alt_smooth && m.alt && !self.began) || self.blob_alt_smooths(m) { + // An active smoothing drag, or Alt before a press that would switch the Pencil, Paintbrush + // or Blob Brush to Smooth. A Paintbrush stroke already drawing keeps drawing with Alt. + if self.smoothing + || (self.id == "pencil" && self.alt_smooth && m.alt && !self.began) + || (!self.active && self.id == "paintbrush" && m.alt && has_selected_smooth_path(cx)) + || self.blob_alt_smooths(m) + { return Cursor::Smooth; } // Before a press that would draw a straight line: over an end of a selected open path, @@ -707,7 +733,7 @@ impl Tool for GestureTool { // Alt held at the release closes the path (`finish`). // A straight line stays open, so its release has nothing to close, and before a press Alt // gives the Pencil a straight line. - if self.draws_path() && !self.alt_smooth && m.alt && !self.straight && (self.active || self.id != "pencil") { + if self.draws_path() && (self.id == "paintbrush" || !self.alt_smooth) && m.alt && !self.straight && (self.active || self.id != "pencil") { return if self.id == "paintbrush" { Cursor::PaintbrushClose } else { Cursor::PencilClose }; } // A release near a freehand stroke's start closes it too (`finish`): the ring shows while @@ -789,7 +815,7 @@ impl Tool for GestureTool { "altSmooth" => self.alt_smooth = v.as_bool().unwrap_or(self.alt_smooth), "livePreview" if self.id == "pencil" => self.live_preview = v.as_bool().unwrap_or(self.live_preview), "liveFit" if self.id == "pencil" => self.live_fit = v.as_bool().unwrap_or(self.live_fit), - "smoothFidelity" if matches!(self.id, "pencil" | "blobBrush") => { + "smoothFidelity" if matches!(self.id, "pencil" | "paintbrush" | "blobBrush") => { self.smooth_fidelity = v.as_f64().unwrap_or(self.smooth_fidelity).clamp(super::min_fidelity("smooth"), 100.0) } "keepSelected" => self.keep_selected = v.as_bool().unwrap_or(self.keep_selected), diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index c511091b5..17e905156 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -872,7 +872,7 @@ fn pencil_alt_smooths_within_the_smooth_tools_radius() { } } -/// An Alt smoothing drag uses the Smooth tool's Fidelity for both Pencil and Blob Brush. +/// An Alt smoothing drag uses the Smooth tool's Fidelity for Pencil, Paintbrush and Blob Brush. #[test] fn alt_smoothing_uses_the_smooth_tools_fidelity() { let (d, id) = doc_with_rect(); @@ -888,21 +888,93 @@ fn alt_smoothing_uses_the_smooth_tools_fidelity() { }; let alt = Mods { alt: true, ..Default::default() }; let want = fidelity(&drag(&mut GestureTool::new("smooth"), &cx, &pts, Mods::default())); - for id in ["pencil", "blobBrush"] { + for (id, alt_smooth) in [("pencil", true), ("blobBrush", false), ("paintbrush", false), ("paintbrush", true)] { let mut t = GestureTool::new(id); - if id == "pencil" { - t.set_option("altSmooth", &json!(true)); - } + t.set_option("altSmooth", &json!(alt_smooth)); t.set_option("fidelity", &json!(10.0)); - assert_eq!(fidelity(&drag_held(&mut t, &cx, &pts, alt)), want, "{id}"); + assert_eq!(fidelity(&drag_held(&mut t, &cx, &pts, alt)), want, "{id} altSmooth={alt_smooth}"); t.set_option("smoothFidelity", &json!(7.5)); - assert_eq!(fidelity(&drag_held(&mut t, &cx, &pts, alt)), 7.5, "{id}"); + assert_eq!(fidelity(&drag_held(&mut t, &cx, &pts, alt)), 7.5, "{id} altSmooth={alt_smooth}"); // Drawing keeps the tool's own Fidelity; Smooth's setting stays out of its options. assert_eq!(t.options()["fidelity"], json!(10.0), "{id}"); assert!(t.options().get("smoothFidelity").is_none(), "{id}"); } } +/// Alt pressed after a Paintbrush stroke begins closes it, and an empty selection still draws. +#[test] +fn paintbrush_alt_after_press_closes_without_smoothing_and_empty_selection_draws() { + let (d, id) = doc_with_rect(); + let mut selected = Selection::default(); + selected.set([id]); + let empty = Selection::default(); + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let pts = [(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)]; + let mut t = GestureTool::new("paintbrush"); + let selected_cx = cx(&d, &selected, &p); + t.pointer(&selected_cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1)); + t.pointer(&selected_cx, &PointerEvent::new(PointerKind::Drag, pts[1].0, pts[1].1).with_mods(alt)); + let last = t.pointer(&selected_cx, &PointerEvent::new(PointerKind::Up, pts[2].0, pts[2].1).with_mods(alt)); + assert!(matches!(last.first(), Some(Action::Preview(c, v)) if c == "path.freehand" && v["closed"] == true), "{last:?}"); + let cx = cx(&d, &empty, &p); + let last = drag_held(&mut t, &cx, &pts, alt); + assert!(matches!(last.last().and_then(|a| a.first()), Some(Action::Preview(c, v)) if c == "path.freehand" && v["closed"] == true), "{last:?}"); +} + +/// The Paintbrush shows Smooth before an Alt press only with a selected path. +#[test] +fn paintbrush_alt_cursor_matches_selection_at_press() { + let (d, id) = doc_with_rect(); + let mut selected = Selection::default(); + selected.set([id]); + let empty = Selection::default(); + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let t = GestureTool::new("paintbrush"); + assert_eq!(t.cursor(&cx(&d, &selected, &p), Point::new(50.0, 150.0), alt), Cursor::Smooth); + assert_eq!(t.cursor(&cx(&d, &empty, &p), Point::new(50.0, 150.0), alt), Cursor::PaintbrushClose); +} + +/// A selected text object leaves Alt Paintbrush drawing a closed stroke with its close cursor. +#[test] +fn paintbrush_alt_with_only_text_selected_draws_a_closed_stroke() { + let (d, text_id) = doc_with_area_type(); + let mut selected = Selection::default(); + selected.set([text_id]); + let p = paint(); + let cx = cx(&d, &selected, &p); + let alt = Mods { alt: true, ..Default::default() }; + let mut t = GestureTool::new("paintbrush"); + assert_eq!(t.cursor(&cx, Point::new(50.0, 150.0), alt), Cursor::PaintbrushClose); + let acts = drag_held(&mut t, &cx, &[(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)], alt); + assert!(acts.iter().flatten().any(|a| matches!(a, Action::Preview(c, v) if c == "path.freehand" && v["closed"] == true)), "{acts:?}"); + assert!(matches!(acts.last().and_then(|a| a.get(1)), Some(Action::Commit)), "{acts:?}"); +} + +/// A selected two-anchor line leaves Alt Paintbrush drawing a closed stroke with its close cursor. +#[test] +fn paintbrush_alt_with_only_two_anchor_line_selected_draws_a_closed_stroke() { + let (mut d, _) = doc_with_rect(); + let layer = d.layers[0].id; + let id = d.alloc_id(); + let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), + vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), + ])); + d.insert(Some(layer), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut selected = Selection::default(); + selected.set([id]); + let p = paint(); + let cx = cx(&d, &selected, &p); + let alt = Mods { alt: true, ..Default::default() }; + let mut t = GestureTool::new("paintbrush"); + assert_eq!(t.cursor(&cx, Point::new(50.0, 150.0), alt), Cursor::PaintbrushClose); + let acts = drag_held(&mut t, &cx, &[(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)], alt); + assert!(acts.iter().flatten().any(|a| matches!(a, Action::Preview(c, v) if c == "path.freehand" && v["closed"] == true)), "{acts:?}"); + assert!(matches!(acts.last().and_then(|a| a.get(1)), Some(Action::Commit)), "{acts:?}"); +} + /// #3799: Alt's line starts and ends on Smart Guides as a stroke does, and pressed on an end of /// a selected open path it continues the path from that end to the pointer. #[test] diff --git a/crates/tools/src/input.rs b/crates/tools/src/input.rs index f60ac96b5..7698d5741 100644 --- a/crates/tools/src/input.rs +++ b/crates/tools/src/input.rs @@ -38,6 +38,9 @@ pub struct PointerEvent { pub pos: Point, #[serde(default)] pub mods: Mods, + /// Whether backquote is held for this event. + #[serde(default)] + pub backquote: bool, #[serde(default = "one")] pub pressure: f32, } @@ -48,7 +51,7 @@ fn one() -> f32 { impl PointerEvent { pub fn new(kind: PointerKind, x: f64, y: f64) -> Self { - Self { kind, pos: Point::new(x, y), mods: Mods::default(), pressure: 1.0 } + Self { kind, pos: Point::new(x, y), mods: Mods::default(), backquote: false, pressure: 1.0 } } pub fn with_mods(mut self, m: Mods) -> Self { self.mods = m; @@ -65,6 +68,21 @@ impl PointerEvent { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backquote_json_defaults_false_and_decodes_true() { + let event = PointerEvent::new(PointerKind::Down, 10.0, 20.0); + let mut value = serde_json::to_value(event).unwrap(); + value.as_object_mut().unwrap().remove("backquote"); + assert!(!serde_json::from_value::(value.clone()).unwrap().backquote); + value["backquote"] = serde_json::json!(true); + assert!(serde_json::from_value::(value).unwrap().backquote); + } +} + /// Keys tools care about. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum ToolKey { diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 8d839602a..b9ae12f74 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -123,6 +123,10 @@ pub struct SelectionTool { /// The params of the move's last preview: in Shaper's Construction mode the move ends with /// them and `releaseOutside` ([`construction`]). last_move: Option, + /// Held state used for the most recent bounding-box preview. + last_backquote: bool, + /// Pointer position used for the most recent bounding-box preview. + last_box_pos: Option, /// The move snapped to a point or a guide at its last step: the arrow turns hollow. snapped: bool, guide: GuideEdit, @@ -745,8 +749,12 @@ impl Tool for SelectionTool { } } } - (PointerKind::Drag, State::Moving { start, press, hold, began, .. }) => self.drag_moving(cx, p, m, (start, press, hold, began)), + (PointerKind::Drag, State::Moving { start, press, hold, began, .. }) => { + self.drag_moving(cx, p, m, ev.backquote, (start, press, hold, began)) + } (PointerKind::Drag, State::Scaling { handle, bx, areas }) => { + self.last_backquote = ev.backquote; + self.last_box_pos = Some(p); // Scale in the box's own frame: along the objects' axes when it is rotated. let mut a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt); self.guides.clear(); @@ -760,7 +768,7 @@ impl Tool for SelectionTool { // visual box the dragged size and keeps its opposite handle (with Alt, its center) // where it is. A drag that flips the box scales by the matrix. let [sx, _, _, sy, _, _] = a.as_coeffs(); - let (cmd, mut params) = if cx.preview_bounds && cx.selection.anchors.is_empty() && sx > 0.0 && sy > 0.0 { + let (cmd, mut params) = if !ev.backquote && cx.preview_bounds && cx.selection.anchors.is_empty() && sx > 0.0 && sy > 0.0 { let reference = if m.alt { 4 } else { handle.opposite().reference() }; ("object.setBounds", json!({ "width": nr.width(), "height": nr.height(), "reference": reference, "copy": false })) } else { @@ -769,12 +777,23 @@ impl Tool for SelectionTool { if areas { params["typeAreas"] = json!(true); } + if ev.backquote { + params["objects"] = json!(false); + params["patterns"] = json!(true); + } vec![Action::Preview(cmd.into(), params)] } (PointerKind::Drag, State::Rotating { center, start, .. }) => { + self.last_backquote = ev.backquote; + self.last_box_pos = Some(p); let (a, deg) = rotate_for_drag(center, start, p, m.shift); self.measure = cx.transform_tools_guides.then(|| (p, format!("{:.1}°", -deg))); - vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(a), "copy": false }))] + let mut params = json!({ "matrix": matrix_json(a), "copy": false }); + if ev.backquote { + params["objects"] = json!(false); + params["patterns"] = json!(true); + } + vec![Action::Preview("object.transform".into(), params)] } (PointerKind::Drag, State::Marquee { start, toggle, enclosed, .. }) => { self.state = State::Marquee { start, cur: p, toggle, enclosed }; @@ -807,35 +826,21 @@ impl Tool for SelectionTool { self.state = State::Idle; b.finish() } - (PointerKind::Up, State::Moving { began, deselect, key, .. }) => { - self.state = State::Idle; - self.measure = None; - self.guides.clear(); - self.moving = None; - let last = self.last_move.take(); - match (began, deselect, key) { - // In Construction mode, an original shape dropped wholly outside the group's - // bounds leaves the group, in the move's undo step (`releaseOutside`). - (true, ..) => match last.filter(|p| construction(cx) && p["copy"] != json!(true)) { - Some(mut params) => { - params["releaseOutside"] = json!(true); - vec![Action::Preview("object.transform".into(), params), Action::Commit] - } - None => vec![Action::Commit], - }, - (false, Some(id), _) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], - // A click on the key object again: no key. - (false, None, Some(id)) if cx.selection.key == Some(id) => vec![Action::Exec("select.key".into(), json!({}))], - (false, None, Some(id)) if can_be_key(cx, id) => vec![Action::Exec("select.key".into(), json!({ "id": id.0 }))], - (false, None, _) => vec![], - } - } + (PointerKind::Up, State::Moving { began, deselect, key, .. }) => self.release_move(cx, ev.backquote, began, deselect, key), (PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => { + let mut out = if ev.backquote || self.last_backquote { + self.last_box_pos.map_or_else(Vec::new, |pos| self.pointer(cx, &PointerEvent { kind: PointerKind::Drag, pos, ..*ev })) + } else { + vec![] + }; + self.last_backquote = false; + self.last_box_pos = None; self.state = State::Idle; self.measure = None; self.guides.clear(); self.targets = None; - vec![Action::Commit] + out.push(Action::Commit); + out } (PointerKind::Up, State::Marquee { start, toggle, enclosed, .. }) => { self.state = State::Idle; @@ -1042,6 +1047,43 @@ pub(crate) fn handle_cursor(h: Handle, angle: f64) -> Cursor { mod tests; impl SelectionTool { + fn release_move(&mut self, cx: &ToolContext, backquote: bool, began: bool, deselect: Option, key: Option) -> Vec { + self.state = State::Idle; + self.measure = None; + self.guides.clear(); + self.moving = None; + let changed_mode = self.last_move.as_ref().is_some_and(|params| params.get("objects").is_some()) != backquote; + let last = self.last_move.take().map(|mut params| { + if backquote { + params["objects"] = json!(false); + params["patterns"] = json!(true); + } else if let Some(map) = params.as_object_mut() { + map.remove("objects"); + map.remove("patterns"); + } + params + }); + match (began, deselect, key) { + // In Construction mode, an original shape dropped wholly outside the group's + // bounds leaves the group, in the move's undo step (`releaseOutside`). + (true, ..) if backquote || changed_mode || (construction(cx) && last.as_ref().is_some_and(|p| p["copy"] != json!(true))) => match last { + Some(mut params) => { + if construction(cx) && params["copy"] != json!(true) && !backquote { + params["releaseOutside"] = json!(true); + } + vec![Action::Preview("object.transform".into(), params), Action::Commit] + } + None => vec![Action::Commit], + }, + (true, ..) => vec![Action::Commit], + (false, Some(id), _) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], + // A click on the key object again: no key. + (false, None, Some(id)) if cx.selection.key == Some(id) => vec![Action::Exec("select.key".into(), json!({}))], + (false, None, Some(id)) if can_be_key(cx, id) => vec![Action::Exec("select.key".into(), json!({ "id": id.0 }))], + (false, None, _) => vec![], + } + } + fn repeat_controls(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option> { if let State::Mirror(mut d) = self.state.clone() { match ev.kind { @@ -1176,10 +1218,14 @@ impl SelectionTool { // grid, else with Smart Guides to the other objects, else with Snap to // Point to their anchors; the ruler guides with Snap to Point. self.targets = Some(PointSnap::new(cx, || Targets::for_scale(cx))); + self.last_backquote = false; + self.last_box_pos = None; self.state = State::Scaling { handle, bx, areas }; return Some(vec![Action::Begin(label.into())]); } Some(BoxHit::Rotate) => { + self.last_backquote = false; + self.last_box_pos = None; self.state = State::Rotating { center: bx.center(), start: p }; return Some(vec![Action::Begin("Rotate".into())]); } @@ -1202,7 +1248,14 @@ impl SelectionTool { }; } - fn drag_moving(&mut self, cx: &ToolContext, p: Point, m: Mods, (start, press, hold, began): (Point, Point, Point, bool)) -> Vec { + fn drag_moving( + &mut self, + cx: &ToolContext, + p: Point, + m: Mods, + backquote: bool, + (start, press, hold, began): (Point, Point, Point, bool), + ) -> Vec { let mut out = vec![]; if !began { if p.distance(press) < Self::drag_threshold(cx) { @@ -1220,7 +1273,11 @@ impl SelectionTool { self.snapped = (d - unsnapped).hypot() > 1e-9; self.state = State::Moving { start, press, hold, began: true, deselect: None, key: None }; self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y))); - let params = json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }); + let mut params = json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }); + if backquote { + params["objects"] = json!(false); + params["patterns"] = json!(true); + } self.last_move = Some(params.clone()); out.push(Action::Preview("object.transform".into(), params)); out diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 6aace4acd..6ad4a6bf6 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -1301,6 +1301,57 @@ fn handle_drag_scales() { assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]); } +#[test] +fn backquote_move_uses_release_state_and_one_commit() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); + let drag = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, 170.0, 150.0) }); + assert!(matches!(drag.as_slice(), [Action::Begin(_), Action::Preview(_, v)] if v["objects"] == false && v["patterns"] == true)); + let up = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Up, 170.0, 150.0) }); + assert!(matches!(up.as_slice(), [Action::Preview(_, v), Action::Commit] if v["objects"] == false && v["patterns"] == true)); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); + t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, 170.0, 150.0) }); + let up = t.pointer(&cx, &ev(PointerKind::Up, 170.0, 150.0)); + assert!(matches!(up.as_slice(), [Action::Preview(_, v), Action::Commit] if v.get("objects").is_none() && v.get("patterns").is_none())); +} + +#[test] +fn backquote_box_scale_and_rotate_use_pattern_matrix_at_release() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = ToolContext { preview_bounds: true, ..cx(&d, &s, &p) }; + for (from, to) in [((200.0, 200.0), (300.0, 300.0)), ((210.0, 210.0), (150.0, 230.0))] { + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, from.0, from.1)); + let drag = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, to.0, to.1) }); + assert!( + matches!(drag.as_slice(), [Action::Preview(c, v)] if c == "object.transform" && v["objects"] == false && v["patterns"] == true), + "{drag:?}" + ); + let up = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Up, to.0, to.1) }); + assert!( + matches!(up.as_slice(), [Action::Preview(c, v), Action::Commit] if c == "object.transform" && v["objects"] == false && v["patterns"] == true), + "{up:?}" + ); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, from.0, from.1)); + t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, to.0, to.1) }); + let up = t.pointer(&cx, &ev(PointerKind::Up, to.0, to.1)); + assert!( + matches!(up.as_slice(), [Action::Preview(_, v), Action::Commit] if v.get("objects").is_none() && v.get("patterns").is_none()), + "{up:?}" + ); + } +} + /// Escape during a bounding-box scale or rotation cancels it, and the release after it commits /// nothing. #[test] diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs index 1c9d13a94..328ca4a80 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -69,6 +69,8 @@ const ROWS: &[Row] = &[ // The add-ons' tools (`crate::addons`). (&[crate::addons::anchor_eraser::ID], None, &["size", "tolerance", "pressure"]), (&[crate::addons::extend_path::ID], None, &["mode"]), + (&[crate::addons::corner_tool::ID], None, &["radius", "kind"]), + (&[crate::addons::shared_edge::ID], None, &["diameter", "tolerance", "together"]), (&[crate::addons::smart_pen::ID], None, &["next"]), // The kind first: a new kind resets the values after it. (&[crate::addons::parametric_shape::ID], None, &["kind", "count", "a", "b", "radius", "width", "height", "rotation"]), diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 9729985c8..e86bd4c4f 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -202,12 +202,12 @@ pub struct TypeTool { } impl TypeTool { - pub(super) fn step_attribute(&mut self, key: ToolKey, mods: Mods, a: usize, b: usize) -> Option> { + pub(super) fn step_attribute(&mut self, key: ToolKey, mods: Mods, a: usize, b: usize, vertical: bool) -> Option> { if !mods.alt { return None; } let id = self.editing?; - let (attribute, by) = plain::step_key(key, mods.shift, a == b)?; + let (attribute, by) = plain::step_key(key, mods.shift, a == b, vertical)?; let mut out = self.commit(); let by = if mods.cmd { by * 5.0 } else { by }; out.push(Action::Exec("type.step".into(), json!({"id": id.0, "start": a, "end": b, "attribute": attribute, "by": by}))); diff --git a/crates/tools/src/text/plain.rs b/crates/tools/src/text/plain.rs index 32db456c3..6825bfbee 100644 --- a/crates/tools/src/text/plain.rs +++ b/crates/tools/src/text/plain.rs @@ -1,10 +1,22 @@ use super::{ImeSurrounding, Range, ToolKey}; -/// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes). -/// `None` when `r` runs past the end of `s` or backwards. -/// The `type.step` an Alt+arrow asks for, by one step: ←/→ kerning at a `caret`, else tracking; -/// ↑/↓ leading (down opens it up); Shift+↑/↓ baseline shift. -pub fn step_key(key: ToolKey, shift: bool, caret: bool) -> Option<(&'static str, f64)> { +/// Map Alt+arrows to one type step. Horizontal ←/→ changes kerning at a caret or +/// tracking on a range, ↑/↓ changes leading, and Shift+↑/↓ changes baseline shift. +/// Vertical ↑/↓ (with or without Shift) changes kerning or tracking, ←/→ changes +/// leading, and Shift+→/← changes baseline shift. +pub fn step_key(key: ToolKey, shift: bool, caret: bool, vertical: bool) -> Option<(&'static str, f64)> { + let shift = shift && !(vertical && matches!(key, ToolKey::Up | ToolKey::Down)); + let key = if vertical { + match key { + ToolKey::Up => ToolKey::Left, + ToolKey::Down => ToolKey::Right, + ToolKey::Right => ToolKey::Up, + ToolKey::Left => ToolKey::Down, + other => other, + } + } else { + key + }; let sign = |up: bool| if up { 1.0 } else { -1.0 }; Some(match (key, shift) { (ToolKey::Left | ToolKey::Right, false) => (if caret { "kerning" } else { "tracking" }, sign(key == ToolKey::Right)), @@ -14,6 +26,8 @@ pub fn step_key(key: ToolKey, shift: bool, caret: bool) -> Option<(&'static str, }) } +/// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes). +/// `None` when `r` runs past the end of `s` or backwards. pub(super) fn char_range_to_bytes(s: &str, r: Range) -> Option> { let byte = |c: usize| if c == s.chars().count() { Some(s.len()) } else { s.char_indices().nth(c).map(|(i, _)| i) }; let (a, b) = (byte(r.start)?, byte(r.end)?); diff --git a/crates/tools/src/text/tests.rs b/crates/tools/src/text/tests.rs index 1502533d5..172a8613b 100644 --- a/crates/tools/src/text/tests.rs +++ b/crates/tools/src/text/tests.rs @@ -898,6 +898,65 @@ fn marked_text_is_underlined_with_the_converting_clause_thick() { assert!(!tool.composing()); } +#[test] +fn spacing_arrows_follow_each_text_objects_orientation() { + let cases = [ + (ToolKey::Down, ALT, "kerning", 1.0), + (ToolKey::Up, ALT, "kerning", -1.0), + (ToolKey::Left, ALT, "leading", 1.0), + (ToolKey::Right, ALT, "leading", -1.0), + (ToolKey::Right, SHIFT_ALT, "baselineShift", 1.0), + (ToolKey::Left, SHIFT_ALT, "baselineShift", -1.0), + (ToolKey::Down, SHIFT_ALT, "kerning", 1.0), + (ToolKey::Up, SHIFT_ALT, "kerning", -1.0), + (ToolKey::Down, Mods { cmd: true, ..ALT }, "kerning", 5.0), + (ToolKey::Up, Mods { cmd: true, ..ALT }, "kerning", -5.0), + ]; + for kind in 0..3 { + for selected in [false, true] { + let (mut d, id, mut tool) = editing("abcd"); + let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!("text") }; + t.vertical = true; + let path = vectorcraft_geom::PathData::from_bezpath(&Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1)); + t.kind = match kind { + 1 => TextKind::Area { frame: path }, + 2 => TextKind::OnPath { path, start: 0.0, end: None }, + _ => TextKind::Point, + }; + tool.caret = 2; + tool.anchor = if selected { 1 } else { 2 }; + for (key_code, mods, caret_attribute, by) in cases { + let before = (tool.anchor, tool.caret); + let actions = key(&mut tool, &d, key_code, mods); + let step = actions + .iter() + .find_map(|action| match action { + Action::Exec(command, params) if command == "type.step" => Some(params), + _ => None, + }) + .expect("spacing command"); + assert_eq!( + step["attribute"], + if selected && caret_attribute == "kerning" { "tracking" } else { caret_attribute }, + "kind {kind}, selected {selected}" + ); + assert_eq!(step["by"], by); + assert_eq!((tool.anchor, tool.caret), before); + } + } + } + let (mut d, id, mut tool) = editing("abcd"); + let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!("text") }; + t.vertical = true; + key(&mut tool, &d, ToolKey::Down, SHIFT); + assert_eq!((tool.anchor, tool.caret), (0, 1)); + let (d, _, mut tool) = editing("abcd"); + let actions = key(&mut tool, &d, ToolKey::Right, ALT); + assert!(actions.iter().any( + |action| matches!(action, Action::Exec(command, params) if command == "type.step" && params["attribute"] == "kerning" && params["by"] == 1.0) + )); +} + #[test] fn arrows_follow_the_columns_of_vertical_type() { let (mut d, id) = doc_with_text("§§§\n§§§"); diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index cef7f4a9b..a3e27487a 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -175,7 +175,7 @@ impl Tool for TypeTool { self.anchor = self.anchor.min(len); let (a, b) = self.sel(); // Alt+arrows step the type by the Preferences › Type increments (Cmd/Ctrl too: five steps). - if let Some(out) = self.step_attribute(key, mods, a, b) { + if let Some(out) = self.step_attribute(key, mods, a, b, t.vertical) { return out; } let word = mods.cmd || mods.alt; diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index bc4066cc4..de9c9c0cb 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -5,7 +5,7 @@ //! on the interaction snapshot, the utility tools execute selection / paint / artboard commands. mod artboard; -mod eyedropper; +pub(crate) mod eyedropper; mod free; mod freeform; mod gradient; diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index 40778a6c0..5586b987d 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -49,6 +49,25 @@ pub fn paint_color_at(paint: &Paint, bounds: Option, p: } } +/// The visible object under `p`, using the Eyedropper's source selection rules. +pub(crate) fn sample_source(cx: &ToolContext, p: Point) -> Option<(vectorcraft_doc::NodeId, HitKind)> { + let opts = vectorcraft_doc::hit::HitOptions { path_only: false, type_path_only: false, ..cx.hit_options() }; + let h = hit_test_visible(cx.doc, p, opts)?; + Some((paint_owner(cx.doc, h.leaf), h.kind)) +} + +/// The visible color under `p`, including image pixels and intermediate gradient colors. +pub(crate) fn sample_color_at(cx: &ToolContext, p: Point) -> Option { + let (id, kind) = sample_source(cx, p)?; + let n = cx.doc.node(id)?; + if matches!(n.kind, NodeKind::Image(_)) { image_color_at(cx, n, p) } else { paint_sample(n, kind == HitKind::Stroke, p) } +} + +/// Whether the source has fill or stroke paint for an attribute sample. +pub(crate) fn has_sampleable_paint(cx: &ToolContext, id: vectorcraft_doc::NodeId) -> bool { + cx.doc.node(id).is_some_and(|n| [false, true].into_iter().any(|stroke| n.proxy_paint(stroke, None).is_some_and(|(paint, ..)| !paint.is_none()))) +} + /// The colour of `n`'s fill (or its stroke, when the stroke was hit or there is no fill) at `p`, /// sampled in the paint's own space (text space for type runs) and box. fn paint_sample(n: &Node, on_stroke: bool, p: Point) -> Option { @@ -91,13 +110,7 @@ impl Tool for EyedropperTool { // Object Selection by Path Only say. let opts = vectorcraft_doc::hit::HitOptions { path_only: false, type_path_only: false, ..cx.hit_options() }; if self.brush_key_color { - let h = hit_test_visible(cx.doc, ev.pos, opts); - let Some(h) = h else { return vec![] }; - let src = paint_owner(cx.doc, h.leaf); - let Some(n) = cx.doc.node(src) else { return vec![] }; - let image = matches!(n.kind, NodeKind::Image(_)); - let sampled = if image { image_color_at(cx, n, ev.pos) } else { paint_sample(n, h.kind == HitKind::Stroke, ev.pos) }; - let Some(c) = sampled else { return vec![] }; + let Some(c) = sample_color_at(cx, ev.pos) else { return vec![] }; let back = self.stop.clone().unwrap_or_else(|| "selection".to_string()); self.brush_key_color = false; self.stop = None; @@ -106,9 +119,12 @@ impl Tool for EyedropperTool { let stop = self.stop.as_ref().zip(cx.gradient_stop); // Reading a source is independent of its editability. Only Alt's reverse operation // writes to the clicked object; Shift+Alt appends its appearance to the selection. - let h = if ev.mods.alt && !ev.mods.shift && stop.is_none() { hit_test(cx.doc, ev.pos, opts) } else { hit_test_visible(cx.doc, ev.pos, opts) }; - let Some(h) = h else { return vec![] }; - let src = paint_owner(cx.doc, h.leaf); + let source = if ev.mods.alt && !ev.mods.shift && stop.is_none() { + hit_test(cx.doc, ev.pos, opts).map(|h| (paint_owner(cx.doc, h.leaf), h.kind)) + } else { + sample_source(cx, ev.pos) + }; + let Some((src, kind)) = source else { return vec![] }; let Some(n) = cx.doc.node(src) else { return vec![] }; if ev.mods.alt && stop.is_none() { let how = if ev.mods.shift { "append" } else { "reverse" }; @@ -118,7 +134,7 @@ impl Tool for EyedropperTool { if !(ev.mods.shift || stop.is_some() || image) { return vec![Action::Exec("appearance.copyFrom".into(), json!({ "source": src.0 }))]; } - let sampled = if image { image_color_at(cx, n, ev.pos) } else { paint_sample(n, h.kind == HitKind::Stroke, ev.pos) }; + let sampled = if image { image_color_at(cx, n, ev.pos) } else { paint_sample(n, kind == HitKind::Stroke, ev.pos) }; let Some(c) = sampled else { return vec![] }; let color = color_json(&c); let Some((back, i)) = stop else { return vec![Action::Exec("paint.sampleColor".into(), json!({ "color": color }))] }; diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index 04586bc7a..04ecc971b 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -89,8 +89,13 @@ fn about(o: Point, a: Affine) -> Affine { /// The transform `m` of the selection (of a copy with `copy`): of the anchors selected with Direct /// Selection alone, when there are some. -fn preview(m: Affine, copy: bool) -> Action { - Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(m), "copy": copy, "anchors": true })) +fn preview(m: Affine, copy: bool, backquote: bool) -> Action { + let mut params = json!({ "matrix": matrix_json(m), "copy": copy, "anchors": true }); + if backquote { + params["objects"] = json!(false); + params["patterns"] = json!(true); + } + Action::Preview("object.transform".into(), params) } /// Reflection across the line through the origin at angle `theta` (radians, screen space). @@ -243,14 +248,14 @@ impl Tool for TransformTool { d.last = m; self.drag = Some(d); self.measure = Some((p, text)); - out.push(preview(m, ev.mods.alt)); + out.push(preview(m, ev.mods.alt, ev.backquote)); out } PointerKind::Up => { let Some(d) = self.end_drag() else { return vec![] }; if d.began { // The Alt state at release decides whether the result is a copy. - vec![preview(d.last, ev.mods.alt), Action::Commit] + vec![preview(d.last, ev.mods.alt, ev.backquote), Action::Commit] } else { // A click sets the reference point; with Alt, it opens the dialog too. self.origin = Some((d.pick, cx.selection.objects.clone())); @@ -343,6 +348,51 @@ mod tests { fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { PointerEvent::new(kind, x, y) } + #[test] + fn backquote_drag_previews_and_commits_pattern_only_for_each_transform_tool() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + for kind in [TransformKind::Rotate, TransformKind::Reflect, TransformKind::Scale, TransformKind::Shear] { + let mut t = TransformTool::new(kind); + t.pointer(&cx, &ev(PointerKind::Down, 250.0, 170.0)); + let drag = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, 290.0, 210.0) }); + assert!( + matches!(drag.as_slice(), [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["objects"] == false && v["patterns"] == true), + "{kind:?}: {drag:?}" + ); + assert_ne!(matrix(&drag[1]), Affine::IDENTITY, "{kind:?}"); + let up = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Up, 290.0, 210.0) }); + assert!( + matches!(up.as_slice(), [Action::Preview(_, v), Action::Commit] if v["objects"] == false && v["patterns"] == true), + "{kind:?}: {up:?}" + ); + let mut t = TransformTool::new(kind); + t.pointer(&cx, &ev(PointerKind::Down, 250.0, 170.0)); + t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, 290.0, 210.0) }); + let up = t.pointer(&cx, &ev(PointerKind::Up, 290.0, 210.0)); + assert!( + matches!(up.as_slice(), [Action::Preview(_, v), Action::Commit] if v.get("objects").is_none() && v.get("patterns").is_none()), + "{kind:?}: {up:?}" + ); + let mut t = TransformTool::new(kind); + t.pointer(&cx, &ev(PointerKind::Down, 250.0, 170.0)); + t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, 290.0, 210.0) }); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert!(t.pointer(&cx, &ev(PointerKind::Up, 290.0, 210.0)).is_empty()); + } + let mut t = TransformTool::new(TransformKind::Scale); + t.pointer(&cx, &ev(PointerKind::Down, 250.0, 170.0)); + let mods = Mods { alt: true, shift: true, ..Default::default() }; + let drag = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, 290.0, 210.0).with_mods(mods) }); + assert!(matches!(&drag[1], Action::Preview(_, v) if v["copy"] == true && v["objects"] == false && v["patterns"] == true)); + let c = matrix(&drag[1]).as_coeffs(); + assert!((c[0] - c[3]).abs() < 1e-9); + let up = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Up, 290.0, 210.0).with_mods(mods) }); + assert!(matches!(&up[0], Action::Preview(_, v) if v["copy"] == true && v["objects"] == false)); + } fn alt() -> Mods { Mods { alt: true, ..Default::default() } } diff --git a/crates/ui-egui/src/addons/cleanup.rs b/crates/ui-egui/src/addons/cleanup.rs index 66c7f537d..d44fe7b39 100644 --- a/crates/ui-egui/src/addons/cleanup.rs +++ b/crates/ui-egui/src/addons/cleanup.rs @@ -1,7 +1,8 @@ //! Artwork Cleanup's panel and submenu (Object › Plug-ins › Artwork Cleanup). The panel runs the //! Document Health Check on the selection, or the document while nothing is selected, lists what it //! found, and shows or fixes the issues picked in the list (Fix All: every issue listed), each fix -//! one undo step. Below, the add-on's one-click tools open their options as their menu items do. +//! one undo step. Below, the add-on's one-click tools open their options as their menu items do, +//! and its text tools, which the panel can hide, act on the selected type. use egui::{Align, Layout, Ui}; use serde_json::{Value, json}; @@ -16,6 +17,8 @@ pub const ID: &str = "artworkCleanup"; /// Where the panel keeps its last check (panel state). const STATE: &str = "artwork-cleanup"; +/// Where the panel keeps whether its text tools are hidden (panel state). +const TEXT_HIDDEN: &str = "artwork-cleanup-text-hidden"; /// The one-click tools: (menu label, command, the options their dialog opens with). fn tools() -> [(&'static str, &'static str, Value); 3] { @@ -26,11 +29,92 @@ fn tools() -> [(&'static str, &'static str, Value); 3] { ] } +/// Replace Missing Fonts' options: the default type font. +fn replace_fonts() -> Value { + json!({ "font": vectorcraft_doc::CharStyle::default().font_family }) +} + +/// A row of the panel's text tools: a label above (if any), then its buttons (label, what a +/// modifier click does, command, params). +type TextRow = (Option<&'static str>, Vec<(&'static str, Option<&'static str>, &'static str, Value)>); + +fn text_rows() -> [TextRow; 4] { + let align = |a: &str| json!({ "align": a }); + let by = |b: &str| json!({ "by": b }); + [ + ( + Some("Set Alignment, Keep Position:"), + vec![ + ("Left", None, "addon.cleanup.alignInPlace", align("left")), + ("Center", None, "addon.cleanup.alignInPlace", align("center")), + ("Right", None, "addon.cleanup.alignInPlace", align("right")), + ], + ), + ( + Some("Break Text into:"), + vec![ + ("Lines", Some("Shift-click: break into paragraphs"), "addon.cleanup.breakText", by("lines")), + ("Words", None, "addon.cleanup.breakText", by("words")), + ("Glyphs", None, "addon.cleanup.breakText", by("glyphs")), + ], + ), + ( + None, + vec![ + ("Split at Cursor", None, "addon.cleanup.splitText", json!({})), + ("Reset Transform", Some("Alt-click: reset the horizontal scale too"), "addon.cleanup.resetTextTransform", json!({})), + ], + ), + (None, vec![("Combine Point Text", None, "addon.cleanup.combineText", json!({}))]), + ] +} + +/// The text tools one to a row below the others: (label, command, params). +fn text_singles() -> [(&'static str, &'static str, Value); 3] { + [ + ("Remove Line Breaks", "addon.cleanup.removeBreaks", json!({ "kind": "line" })), + ("Remove Paragraph Breaks", "addon.cleanup.removeBreaks", json!({ "kind": "paragraph" })), + ("Replace Missing Fonts…", "addon.cleanup.replaceFonts", replace_fonts()), + ] +} + +/// `p` for a click with `held`: Shift-click on Lines breaks into paragraphs, Alt-click on Reset +/// Transform resets the horizontal scale too. +fn modified(id: &str, mut p: Value, held: egui::Modifiers) -> Value { + match id { + "addon.cleanup.breakText" if held.shift && p["by"] == "lines" => p["by"] = json!("paragraphs"), + "addon.cleanup.resetTextTransform" if held.alt => p["scale"] = json!(true), + _ => {} + } + p +} + /// Object › Plug-ins › Artwork Cleanup. pub fn menu() -> Vec { let mut items = vec![Item::Cmd("Document Health Check", "window.panel", json!({ "panel": ID })), Item::Sep]; items.extend(tools().into_iter().map(|(label, id, p)| Item::Cmd(label, id, p))); - items.extend([Item::Sep, Item::Cmd("Fix All Issues", "addon.cleanup.fix", Value::Null)]); + items.extend([Item::Sep, Item::Cmd("Fix All Issues", "addon.cleanup.fix", Value::Null), Item::Sep]); + let by = |b: &str| json!({ "by": b }); + let align = |a: &str| json!({ "align": a }); + items.extend([ + Item::Cmd("Combine Point Text", "addon.cleanup.combineText", json!({})), + Item::Cmd("Break Text into Lines", "addon.cleanup.breakText", by("lines")), + Item::Cmd("Break Text into Paragraphs", "addon.cleanup.breakText", by("paragraphs")), + Item::Cmd("Break Text into Words", "addon.cleanup.breakText", by("words")), + Item::Cmd("Break Text into Glyphs", "addon.cleanup.breakText", by("glyphs")), + Item::Cmd("Split Text at Cursor", "addon.cleanup.splitText", json!({})), + Item::Sub( + "Set Alignment, Keep Position", + vec![ + Item::Cmd("Left", "addon.cleanup.alignInPlace", align("left")), + Item::Cmd("Center", "addon.cleanup.alignInPlace", align("center")), + Item::Cmd("Right", "addon.cleanup.alignInPlace", align("right")), + ], + ), + Item::Cmd("Reset Text Transform", "addon.cleanup.resetTextTransform", json!({})), + Item::Cmd("Reset Text Transform and Scale", "addon.cleanup.resetTextTransform", json!({ "scale": true })), + ]); + items.extend(text_singles().into_iter().map(|(label, id, p)| Item::Cmd(label, id, p))); items } @@ -94,7 +178,8 @@ enum Action { Check, Show(String), Fix(Vec), - Tool(usize), + /// A tool's button: (its label, command, params), run as its menu item is. + Run(&'static str, &'static str, Value), } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -121,12 +206,21 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } widgets::divider(ui); ui.add_enabled_ui(selected, |ui| { - for (i, (label, ..)) in tools().iter().enumerate() { + for (label, id, p) in tools() { if widgets::flat_button(ui, tl!(label), width).clicked() { - action = Some(Action::Tool(i)); + action = Some(Action::Run(label, id, p)); } } }); + let hidden: bool = pstate(ui.ctx(), TEXT_HIDDEN); + widgets::divider(ui); + if widgets::section_toggle(ui, "Text", !hidden) { + set_pstate(ui.ctx(), TEXT_HIDDEN, !hidden); + } + if !hidden { + let editing = app.session.tool_options().get("editing").is_some_and(|v| !v.is_null()); + text_tools(ui, width, selected, editing, &mut action); + } match action { Some(Action::Check) => r = check(app, None), Some(Action::Show(kind)) => { @@ -135,17 +229,53 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } Some(Action::Fix(kinds)) => r = fix(app, &r, kinds), - Some(Action::Tool(i)) => { - if let Some((label, id, p)) = tools().into_iter().nth(i) { - let (id, p) = crate::menus::click_target(label, id, &p); - crate::menus::invoke(app, &id, p); - } + Some(Action::Run(label, id, p)) => { + let (id, p) = crate::menus::click_target(label, id, &p); + crate::menus::invoke(app, &id, p); } None => {} } set_pstate(ui.ctx(), STATE, r); } +/// The text tools: rows of buttons, then one to a row. Each is enabled when its command can act: +/// Split at Cursor while a Type tool edits text, Replace Missing Fonts always, the others with a +/// selection. +fn text_tools(ui: &mut Ui, width: f32, selected: bool, editing: bool, action: &mut Option) { + let enabled = |id: &str| match id { + "addon.cleanup.splitText" => editing, + "addon.cleanup.replaceFonts" => true, + _ => selected, + }; + let mut button = |ui: &mut Ui, label: &'static str, tip: Option<&str>, id: &'static str, p: Value, w: f32| { + // Lines and words of text, not lines drawn: their own catalog rows (the button translates + // the others). + let shown = if id == "addon.cleanup.breakText" { crate::i18n::tr_ctx(crate::i18n::current(), "breakText", label) } else { label }; + let mut resp = ui.add_enabled_ui(enabled(id), |ui| widgets::flat_button(ui, shown, w)).inner; + if let Some(tip) = tip { + resp = resp.on_hover_text(tl!(tip)); + } + if resp.clicked() { + *action = Some(Action::Run(label, id, modified(id, p, ui.input(|i| i.modifiers)))); + } + }; + for (heading, buttons) in text_rows() { + if let Some(h) = heading { + widgets::subheader(ui, h); + } + ui.horizontal(|ui| { + let n = buttons.len() as f32; + let each = ((width - ui.spacing().item_spacing.x * (n - 1.0)) / n).max(40.0); + for (label, tip, id, p) in buttons { + button(ui, label, tip, id, p, each); + } + }); + } + for (label, id, p) in text_singles() { + button(ui, label, None, id, p, width); + } +} + /// The list of what the check found (click picks a row, Ctrl/Cmd- or Shift-click adds one), and /// Show, Fix Selected and Fix All. fn results(ui: &mut Ui, r: &mut Results, action: &mut Option) { diff --git a/crates/ui-egui/src/addons/cleanup/tests.rs b/crates/ui-egui/src/addons/cleanup/tests.rs index 041a3410b..edf715924 100644 --- a/crates/ui-egui/src/addons/cleanup/tests.rs +++ b/crates/ui-egui/src/addons/cleanup/tests.rs @@ -65,3 +65,111 @@ fn check_lists_what_it_found_and_fix_clears_it() { }); assert!(text.contains("No issues found."), "{text}"); } + +/// An app with the point type `text`, selected. +fn type_app(text: &str) -> (VectorcraftApp, u64) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 60, "text": text, "size": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + (app, id) +} + +fn texts(app: &VectorcraftApp) -> Vec { + let mut out = vec![]; + for l in &app.session.doc().unwrap().doc.layers { + l.walk(&mut |n| { + if let vectorcraft_doc::NodeKind::Text(t) = &n.kind { + out.push(t.plain_text()); + } + }); + } + out +} + +#[test] +fn the_plugins_submenu_runs_the_text_tools() { + let (mut app, _) = type_app("one two"); + let entries = menus::menu_entries(&app); + let under = |path: &[&str], label: &str| entries.iter().find(|e| e.path == path && e.label == label); + let cleanup = ["Object", "Plug-ins", "Artwork Cleanup"]; + for (label, id) in [ + ("Combine Point Text", "addon.cleanup.combineText"), + ("Break Text into Lines", "addon.cleanup.breakText"), + ("Break Text into Paragraphs", "addon.cleanup.breakText"), + ("Break Text into Glyphs", "addon.cleanup.breakText"), + ("Split Text at Cursor", "addon.cleanup.splitText"), + ("Reset Text Transform", "addon.cleanup.resetTextTransform"), + ("Reset Text Transform and Scale", "addon.cleanup.resetTextTransform"), + ("Remove Line Breaks", "addon.cleanup.removeBreaks"), + ("Remove Paragraph Breaks", "addon.cleanup.removeBreaks"), + ("Replace Missing Fonts…", "addon.cleanup.replaceFonts"), + ] { + assert_eq!(under(&cleanup, label).and_then(|e| e.command.as_deref()), Some(id), "{label}"); + } + let center = under(&["Object", "Plug-ins", "Artwork Cleanup", "Set Alignment, Keep Position"], "Center").expect("listed"); + assert_eq!((center.command.as_deref(), ¢er.params), (Some("addon.cleanup.alignInPlace"), &json!({"align": "center"}))); + // Replace Missing Fonts opens its options first, with the default type font. + let fonts = under(&cleanup, "Replace Missing Fonts…").unwrap(); + let (target, p) = menus::click_target(&fonts.label, "addon.cleanup.replaceFonts", &fonts.params); + assert_eq!((target.as_str(), &p["params"]["font"]), ("ui.paramDialog", &json!("Source Sans 3"))); + let words = under(&cleanup, "Break Text into Words").unwrap(); + menus::invoke(&mut app, "addon.cleanup.breakText", words.params.clone()); + assert_eq!(texts(&app), ["one", "two"]); + let combine = under(&cleanup, "Combine Point Text").unwrap(); + menus::invoke(&mut app, "addon.cleanup.combineText", combine.params.clone()); + assert_eq!(texts(&app), ["one two"]); +} + +/// One frame of the panel in `ctx` with `events` and `modifiers` held: the texts drawn and where. +fn frame_with(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, modifiers: egui::Modifiers) -> Vec<(String, egui::Rect)> { + fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let time = ctx.input(|i| i.time) + 0.1; + let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(); + let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| collect(&c.shape, &mut v)); + v +} + +fn panel_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { + frame_with(app, ctx, events, egui::Modifiers::NONE) +} + +/// Click the text `label` the panel draws, with `modifiers` held. +fn click(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str, modifiers: egui::Modifiers) { + let shown = panel_frame(app, ctx, vec![]); + let at = shown.iter().find(|(s, _)| s == label).unwrap_or_else(|| panic!("no {label:?} in {shown:?}")).1.center(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers }; + for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] { + frame_with(app, ctx, events, modifiers); + } +} + +#[test] +fn the_panels_text_row_runs_the_text_tools_and_hides() { + let (mut app, _) = type_app("Hello there\nworld"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let steps = app.session.doc().unwrap().history.undo.len(); + // Shift-click on Lines breaks into paragraphs. + click(&mut app, &ctx, "Lines", egui::Modifiers::SHIFT); + assert_eq!(texts(&app), ["Hello there", "world"]); + assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); + click(&mut app, &ctx, "Words", egui::Modifiers::NONE); + assert_eq!(texts(&app), ["Hello", "there", "world"]); + click(&mut app, &ctx, "Combine Point Text", egui::Modifiers::NONE); + assert_eq!(texts(&app), ["Hello there", "world"]); + // The row hides and shows again. + click(&mut app, &ctx, "Text", egui::Modifiers::NONE); + assert!(!panel_frame(&mut app, &ctx, vec![]).iter().any(|(s, _)| s == "Words")); + click(&mut app, &ctx, "Text", egui::Modifiers::NONE); + assert!(panel_frame(&mut app, &ctx, vec![]).iter().any(|(s, _)| s == "Words")); +} diff --git a/crates/ui-egui/src/addons/pathtools.rs b/crates/ui-egui/src/addons/pathtools.rs index 54df402ed..b55c38ccd 100644 --- a/crates/ui-egui/src/addons/pathtools.rs +++ b/crates/ui-egui/src/addons/pathtools.rs @@ -1,16 +1,19 @@ //! Path Precision Tools in the interface: its submenu (Object › Plug-ins › Path Precision Tools), //! which selects its tools, opens its panels and runs Split / Add Points at Crossings and Convert //! to Parametric Shape; the Anchor Inspector panel (the selected anchor's position and handles as -//! numbers, and the add-on's anchor edits); the Parametric Shape panel ([`shape`]); and its tools' -//! options dialog (a double-click on the tool). Everything acts through `addon.pathtools.*` and +//! numbers, the add-on's anchor edits, the Corner Tool's values, [`corner`], and the Shared Edge +//! Merge Tool's, [`shared_edge`]); the Parametric Shape panel ([`shape`]); and its tools' options +//! dialog (a double-click on the tool). Everything acts through `addon.pathtools.*` and //! `tool.setOption`. +pub mod corner; pub mod shape; +pub mod shared_edge; use egui::Ui; use serde_json::{Map, Value, json}; use vectorcraft_geom::Point; -use vectorcraft_tools::addons::{anchor_eraser, extend_path, measure_inspect, parametric_shape}; +use vectorcraft_tools::addons::{anchor_eraser, extend_path, measure_inspect, parametric_shape, slide_anchor}; use crate::VectorcraftApp; use crate::dialogs::DialogSpec; @@ -29,6 +32,20 @@ pub const KIND: &str = "pathToolOptions"; const CLOSE_MODES: [(&str, &str); 3] = [("handles", "Keep Handles"), ("straight", "Straight"), ("smooth", "Smooth")]; const LABEL_W: f32 = 64.0; +/// The parameters of the add-on's Path Effects that pick one of some values, as (label, value). +pub(crate) fn effect_choices(effect: &str, key: &str) -> Option<&'static [(&'static str, &'static str)]> { + use vectorcraft_effects::addons::path as fx; + match (effect, key) { + (fx::ADD_ANCHORS, "target") => Some(&[("All", "all"), ("Curved", "curved"), ("Straight", "straight")]), + (fx::ADD_ANCHORS, "spacing") => Some(&[("By Count", "count"), ("By Distance", "distance")]), + (fx::EXTEND, "startShape" | "endShape") => Some(&[("Straight", "straight"), ("Arc", "arc")]), + (fx::CLOSE, "mode") => Some(&[("Straight", "straight"), ("Smooth", "smooth")]), + (fx::RETRACT, "handles") => Some(&[("All", "all"), ("Shorter Than Length", "shorter")]), + (fx::SPLIT, "anchors") => Some(&[("Corner", "corner"), ("Smooth", "smooth"), ("Both", "both")]), + _ => None, + } +} + /// Object › Plug-ins › Path Precision Tools. pub fn menu() -> Vec { let tool = |label, id: &str| Item::Cmd(label, "tool.select", json!({ "tool": id })); @@ -37,6 +54,9 @@ pub fn menu() -> Vec { tool("Extend Path Tool", extend_path::ID), tool("Measure & Inspect Tool", measure_inspect::ID), tool("Parametric Shape Tool", parametric_shape::ID), + Item::Cmd("Corner Tool", corner::COMMAND, json!({})), + tool("Slide Anchor Along Path", slide_anchor::ID), + Item::Cmd("Shared Edge Merge Tool", shared_edge::COMMAND, json!({})), Item::Sep, Item::Cmd("Anchor Inspector", "window.panel", json!({ "panel": ID })), Item::Cmd("Parametric Shape", "window.panel", json!({ "panel": shape::ID })), @@ -154,6 +174,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some((id, p)) = action { run(app, id, p); } + widgets::divider(ui); + corner::section(app, ui); + widgets::divider(ui); + shared_edge::section(app, ui); } // ---------- the tools' options ---------- diff --git a/crates/ui-egui/src/addons/pathtools/corner.rs b/crates/ui-egui/src/addons/pathtools/corner.rs new file mode 100644 index 000000000..dce136335 --- /dev/null +++ b/crates/ui-egui/src/addons/pathtools/corner.rs @@ -0,0 +1,64 @@ +//! The Anchor Inspector panel's Corners section: the Corner Tool's radius and kind, how many corners +//! the selection holds, and Apply (`addon.pathtools.cornerSet`). + +use egui::Ui; +use serde_json::{Value, json}; +use vectorcraft_doc::LiveCorners; +use vectorcraft_geom::shapes::CornerKind; +use vectorcraft_tools::addons::corner_tool::{ID, MAX_RADIUS, RADIUS}; + +use super::{LABEL_W, run}; +use crate::VectorcraftApp; +use crate::dialogs::corners::kind_label; +use crate::widgets::{self, dim_label}; + +/// The menu's command: the tool, with the panel its values are in. +pub const COMMAND: &str = "addon.pathtools.cornerTool"; +const FIELD_W: f32 = 96.0; + +/// Object › Plug-ins › Path Precision Tools › Corner Tool. +pub fn open(app: &mut VectorcraftApp) -> Result { + app.select_tool(ID); + app.run("window.panel", json!({ "panel": super::ID })) +} + +/// How many corners Apply would edit: the selected paths' direct-selected ones, or all of a path +/// selected whole. +fn selected_corners(app: &VectorcraftApp) -> usize { + let Some(st) = app.session.active() else { return 0 }; + let of = |id| { + let lc = st.doc.node(id).and_then(LiveCorners::of)?; + Some(st.selection.partial(id).filter(|a| !a.is_empty()).map_or(lc.corners.len(), |a| lc.corners_of(a).len())) + }; + st.selection.objects.iter().filter_map(|id| of(*id)).sum() +} + +pub(super) fn section(app: &mut VectorcraftApp, ui: &mut Ui) { + let options = app.session.tool_options_of(ID); + let radius = options["radius"].as_f64().unwrap_or(RADIUS); + let kind = serde_json::from_value::(options["kind"].clone()).unwrap_or_default(); + let count = selected_corners(app); + let unit = app.session.general_unit(); + widgets::subheader(ui, "Corners"); + dim_label(ui, &crate::i18n::fmt(tl!("Corners selected: {count}"), &[("count", &count.to_string())])); + let mut set: Option = None; + widgets::label_row(ui, tl!("Radius:"), LABEL_W, |ui| { + if let Some(v) = widgets::size_field(ui, "corner-tool-radius", Some(radius), unit, FIELD_W) { + set = Some(json!({ "radius": v.clamp(0.0, MAX_RADIUS) })); + } + }); + widgets::label_row(ui, tl!("Corner:"), LABEL_W, |ui| { + let labels = CornerKind::ALL.map(kind_label); + if let Some(k) = widgets::dropdown(ui, "corner-tool-kind", kind_label(kind), &labels, FIELD_W).and_then(|i| CornerKind::ALL.get(i)) { + set = Some(json!({ "kind": k })); + } + }); + let width = ui.available_width(); + let apply = ui.add_enabled_ui(count > 0, |ui| widgets::flat_button(ui, tl!("Apply to Selected Anchors"), width).clicked()).inner; + if let Some(values) = set { + run(app, "tool.setOption", json!({ "tool": ID, "values": values })); + } + if apply { + run(app, "addon.pathtools.cornerSet", json!({ "radius": radius, "kind": kind })); + } +} diff --git a/crates/ui-egui/src/addons/pathtools/shared_edge.rs b/crates/ui-egui/src/addons/pathtools/shared_edge.rs new file mode 100644 index 000000000..2e1365ffa --- /dev/null +++ b/crates/ui-egui/src/addons/pathtools/shared_edge.rs @@ -0,0 +1,48 @@ +//! The Anchor Inspector panel's Shared Edges section: the Shared Edge Merge Tool's brush and +//! coincidence, and Keep Shared Points Together. + +use egui::Ui; +use serde_json::{Value, json}; +use vectorcraft_tools::addons::shared_edge::{COINCIDENCE, DIAMETER, DIAMETER_RANGE, ID, MAX_COINCIDENCE}; + +use super::run; +use crate::VectorcraftApp; +use crate::widgets; + +/// The menu's command: the tool, with the panel its values are in. +pub const COMMAND: &str = "addon.pathtools.sharedEdgeTool"; +const FIELD_W: f32 = 96.0; +/// Wide enough for "Coincidence:". +const LABEL_W: f32 = 80.0; + +/// Object › Plug-ins › Path Precision Tools › Shared Edge Merge Tool. +pub fn open(app: &mut VectorcraftApp) -> Result { + app.select_tool(ID); + app.run("window.panel", json!({ "panel": super::ID })) +} + +pub(super) fn section(app: &mut VectorcraftApp, ui: &mut Ui) { + let options = app.session.tool_options_of(ID); + let diameter = options["diameter"].as_f64().unwrap_or(DIAMETER); + let tolerance = options["tolerance"].as_f64().unwrap_or(COINCIDENCE); + let together = options["together"].as_bool().unwrap_or(false); + widgets::subheader(ui, "Shared Edges"); + let mut set: Option = None; + widgets::label_row(ui, tl!("Diameter:"), LABEL_W, |ui| { + if let Some(v) = widgets::range_field(ui, "shared-edge-diameter", diameter, DIAMETER_RANGE.0..=DIAMETER_RANGE.1, " pt", 2, FIELD_W) { + set = Some(json!({ "diameter": v })); + } + }); + widgets::label_row(ui, tl!("Coincidence:"), LABEL_W, |ui| { + if let Some(v) = widgets::range_field(ui, "shared-edge-tolerance", tolerance, 0.0..=MAX_COINCIDENCE, " pt", 3, FIELD_W) { + set = Some(json!({ "tolerance": v })); + } + }); + let toggled = widgets::check(ui, "Keep Shared Points Together", together, true); + if let Some(values) = set { + run(app, "tool.setOption", json!({ "tool": ID, "values": values })); + } + if toggled { + run(app, "addon.pathtools.movePointsTogether", json!({ "enabled": !together })); + } +} diff --git a/crates/ui-egui/src/addons/pathtools/tests.rs b/crates/ui-egui/src/addons/pathtools/tests.rs index 9965773b9..e69c93042 100644 --- a/crates/ui-egui/src/addons/pathtools/tests.rs +++ b/crates/ui-egui/src/addons/pathtools/tests.rs @@ -21,6 +21,7 @@ fn the_plugins_submenu_selects_the_tools_and_opens_the_anchor_inspector() { ("Extend Path Tool", "extendPath"), ("Measure & Inspect Tool", "measureInspect"), ("Parametric Shape Tool", "parametricShape"), + ("Slide Anchor Along Path", "slideAnchor"), ] { let e = under(label).expect(label); assert_eq!((e.command.as_deref(), &e.params), (Some("tool.select"), &json!({ "tool": tool }))); @@ -113,3 +114,118 @@ fn the_parametric_shape_panel_opens_from_the_submenu_and_shows_the_shape() { assert!(text.contains("Speech Bubble") && text.contains("Tail:") && text.contains("Tip:") && !text.contains("With none selected"), "{text}"); assert!(!text.contains("Count:"), "a speech bubble has no count: {text}"); } + +#[test] +fn the_corner_tool_item_selects_the_tool_and_opens_its_panel_section() { + let mut app = app(); + let entries = menus::menu_entries(&app); + let e = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Path Precision Tools"] && e.label == "Corner Tool").expect("listed"); + assert!(!crate::panel_docking::showing(&app.ui, ID)); + menus::invoke(&mut app, e.command.as_deref().unwrap(), e.params.clone()); + assert_eq!(app.session.tool_id(), "cornerTool"); + assert!(crate::panel_docking::showing(&app.ui, ID), "the panel opens"); + // Neither toolbar shows it, the All Tools list does. + assert!( + vectorcraft_tools::catalog::all_tools().any(|t| t.id == "cornerTool") + && vectorcraft_tools::catalog::all_tools().any(|t| t.id == "slideAnchor") + ); + assert!(!vectorcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).any(|t| t.id == "cornerTool" || t.id == "slideAnchor")); +} + +#[test] +fn the_shared_edge_item_selects_the_tool_and_opens_its_panel_section() { + let mut app = app(); + let entries = menus::menu_entries(&app); + let e = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Path Precision Tools"] && e.label == "Shared Edge Merge Tool").expect("listed"); + assert!(!crate::panel_docking::showing(&app.ui, ID)); + menus::invoke(&mut app, e.command.as_deref().unwrap(), e.params.clone()); + assert_eq!(app.session.tool_id(), "sharedEdge"); + assert!(crate::panel_docking::showing(&app.ui, ID), "the panel opens"); + // Neither toolbar shows it and it has no shortcut; the All Tools list does. + let tool = vectorcraft_tools::catalog::all_tools().find(|t| t.id == "sharedEdge").expect("in All Tools"); + assert!(tool.shortcut.is_none()); + assert!(!vectorcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).any(|t| t.id == "sharedEdge")); + // The panel's section shows the tool's values and its option acts. + let text = painted_text(&mut app, show); + assert!(text.contains("Shared Edges") && text.contains("Coincidence:") && text.contains("Keep Shared Points Together"), "{text}"); + app.run("addon.pathtools.movePointsTogether", json!({ "enabled": true })).unwrap(); + assert_eq!(app.session.tool_options_of("sharedEdge")["together"], json!(true)); +} + +#[test] +fn the_corner_section_shows_the_tools_values_and_applies_them_to_the_selection() { + let mut app = app(); + let r = app + .run("path.create", json!({"anchors": [{"x": 10, "y": 10}, {"x": 110, "y": 10}, {"x": 110, "y": 110}, {"x": 10, "y": 110}], "closed": true})) + .unwrap(); + let id = vectorcraft_doc::NodeId(r["id"].as_u64().unwrap()); + let text = painted_text(&mut app, show); + assert!( + text.contains("Corners selected: 4") && text.contains("Radius:") && text.contains("Round") && text.contains("Apply to Selected Anchors"), + "{text}" + ); + app.run("tool.setOption", json!({"tool": "cornerTool", "values": {"radius": 12, "kind": "chamfer"}})).unwrap(); + assert!(painted_text(&mut app, show).contains("Chamfer")); + app.session + .select(|_, sel| { + sel.set([id]); + sel.anchors.insert(id, [(0, 1), (0, 2)].into_iter().collect()); + }) + .unwrap(); + assert!(painted_text(&mut app, show).contains("Corners selected: 2")); + // What Apply runs: the tool's values on the selected corners, one undo step. + let steps = app.session.doc().unwrap().history.undo.len(); + let options = app.session.tool_options_of("cornerTool"); + app.run("addon.pathtools.cornerSet", json!({"radius": options["radius"], "kind": options["kind"]})).unwrap(); + assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1); + assert_eq!(app.session.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchor_count(), 6); + assert!(painted_text(&mut app, show).contains("Corners selected: 2"), "still selected"); +} + +#[test] +fn effect_path_effects_lists_the_live_effects_and_their_dialogs_pick_from_their_values() { + let mut app = app(); + let entries = menus::menu_entries(&app); + let at = |label: &str| entries.iter().find(|e| e.path == ["Effect", "Path Effects"] && e.label == label); + for label in [ + "Dashes to Geometry…", + "Add Anchors…", + "Extend Path…", + "Close Path…", + "Smooth Anchors…", + "Retract Handles…", + "Simplify Anchors…", + "Split at Anchors…", + ] { + assert_eq!(at(label).expect(label).command.as_deref(), Some("effect.dialog"), "{label}"); + } + // No options: applied at once. + assert_eq!(at("Reverse Direction").expect("listed").command.as_deref(), Some("effect.apply")); + assert_eq!(entries.iter().filter(|e| e.path == ["Effect", "Path Effects"]).count(), 9); + // The dialog opens with the effect's parameters, each choice on a list. + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap(); + menus::invoke(&mut app, "effect.dialog", json!({"effect": "addon.path.addAnchors"})); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("effect")); + let text = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert!(text.contains("Add Anchors") && text.contains("Max Count:") && text.contains("All"), "{text}"); + app.ui.dialog = None; + let _ = app.session.cancel_interaction(); + menus::invoke(&mut app, "effect.dialog", json!({"effect": "addon.path.dashes"})); + let text = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert!(text.contains("Dash 1:") && text.contains("Gap 5:") && text.contains("Align to Corners and Ends:"), "{text}"); + for (effect, key) in [ + ("addon.path.addAnchors", "target"), + ("addon.path.addAnchors", "spacing"), + ("addon.path.extend", "startShape"), + ("addon.path.extend", "endShape"), + ("addon.path.close", "mode"), + ("addon.path.retract", "handles"), + ("addon.path.split", "anchors"), + ] { + let doc = vectorcraft_effects::effect_info(effect).unwrap(); + let options = super::effect_choices(effect, key).expect(key); + let default = doc.defaults[key].as_str().unwrap(); + assert!(options.iter().any(|(_, value)| *value == default), "{effect} {key}"); + assert!(options.iter().all(|(_, value)| doc.params.contains(&format!("\"{value}\""))), "{effect} {key}"); + } +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 0e5475b66..923459f89 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -500,6 +500,9 @@ impl VectorcraftApp { titlebar::resize_zones(ui); } self.ui_fonts.frame(&ctx); + if self.ui.dialog.is_none() && !self.ui.palette_open { + shortcuts::remember_text_focus(&ctx); + } scrub::end_frame(self, &ctx); native_menu::sync(self, &ctx); // Observe once, after all of this pass's input, dialogs, floating panels, palette and diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index 986ec60ae..6bcfdfd3f 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -1,4 +1,4 @@ -use super::{Arc, Ordering, VectorcraftApp, graphics, native_menu, shortcuts, take_frame_once}; +use super::{Arc, Ordering, VectorcraftApp, graphics, native_menu, shortcuts, take_frame_once, tools_float}; #[cfg(not(target_arch = "wasm32"))] use super::{fileio, place}; @@ -15,6 +15,7 @@ impl VectorcraftApp { self.write_formats_before_text_input(ctx); native_menu::run(self, ctx); if self.fonts_ready { + tools_float::cancel_move_on_escape(self, ctx); shortcuts::handle(self, ctx); } // A Copy taken in this UI phase (an egui copy event, or a command it runs) is published in diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 58815f2e7..cd2a142be 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -164,6 +164,11 @@ enum Drag { Present, } +/// Whether the canvas is holding a pointer drag across frames. +pub(crate) fn dragging(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::(drag_id()).is_some()) +} + /// Paint the document canvas, complete or cached artwork, navigation and tool overlays. pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/canvas/cursor.rs b/crates/ui-egui/src/canvas/cursor.rs index 535b77fdc..85ec1622a 100644 --- a/crates/ui-egui/src/canvas/cursor.rs +++ b/crates/ui-egui/src/canvas/cursor.rs @@ -152,6 +152,9 @@ pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon { | Cursor::ExtendPath | Cursor::MeasureInspect | Cursor::ParametricShape + | Cursor::CornerTool + | Cursor::SlideAnchor + | Cursor::SharedEdge | Cursor::SmartPen => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)), } } diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 75b155c51..f15fdd608 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -79,13 +79,14 @@ pub(super) fn tool_drag_moved(ui: &Ui, xf: &Xf, p: Pos2, pointer: &egui::Pointer } /// Whether the tool should get another drag event at a pointer held still because Shift, Alt, -/// Cmd/Ctrl or Space went down or up (#4711): a scale switches to proportional or from the centre -/// at once. Records `m` as the modifiers last seen; only once the press has dragged. -pub(super) fn tool_drag_mods_changed(ui: &Ui, m: Mods) -> bool { +/// Cmd/Ctrl, Space or Backtick went down or up (#4711): a scale switches to proportional or from the centre +/// at once. Records `m` and the Backtick state as last seen; only once the press has dragged. +pub(super) fn tool_drag_mods_changed(ui: &Ui, m: Mods, backquote: bool) -> bool { let id = egui::Id::unique("canvas-tool-drag-mods"); - let (dragged, last) = ui.data(|d| (d.get_temp::(tool_drag_doc_id()).is_some(), d.get_temp::(id))); - ui.data_mut(|d| d.insert_temp(id, m)); - dragged && last.is_some_and(|l| l != m) + let held = (m, backquote); + let (dragged, last) = ui.data(|d| (d.get_temp::(tool_drag_doc_id()).is_some(), d.get_temp::<(Mods, bool)>(id))); + ui.data_mut(|d| d.insert_temp(id, held)); + dragged && last.is_some_and(|l| l != held) } /// The pen pressure (0..1) of the press in progress: the force of this frame's pen or touch @@ -169,7 +170,7 @@ pub(super) fn drop_press(app: &mut VectorcraftApp, ui: &Ui, view: vectorcraft_en // and would otherwise outlive the drag that owns it (#2826). // Nothing to roll back without a document; the tool still gets its mouse-up. let _ = app.session.cancel_interaction(); - dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); + dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), backquote: false, pressure: 1.0 }, view); egui::DragAndDrop::clear_payload(ui.ctx()); } Some(_) => {} @@ -249,7 +250,7 @@ fn end_art_move(app: &mut VectorcraftApp, ctx: &egui::Context) { // With no document open there is nothing to roll back; the tool still gets its mouse-up. let _ = app.session.cancel_interaction(); let view = app.view_info(); - dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); + dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), backquote: false, pressure: 1.0 }, view); ctx.data_mut(|d| { d.remove::(drag_id()); d.remove::(drag_id().with("art-gone")); diff --git a/crates/ui-egui/src/canvas/grid.rs b/crates/ui-egui/src/canvas/grid.rs index 15576b9ae..1a978a4c7 100644 --- a/crates/ui-egui/src/canvas/grid.rs +++ b/crates/ui-egui/src/canvas/grid.rs @@ -201,7 +201,13 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c // The new guide shows even if guides were hidden. app.ui.view.guides = true; } - let ev = PointerEvent { kind, pos: p.map_or(Point::ZERO, |p| xf.to_doc(p)), mods: mods(ui.input(|i| i.modifiers), false), pressure: 1.0 }; + let ev = PointerEvent { + kind, + pos: p.map_or(Point::ZERO, |p| xf.to_doc(p)), + mods: mods(ui.input(|i| i.modifiers), false), + backquote: ui.input(|i| i.key_down(egui::Key::Backtick)), + pressure: 1.0, + }; if let Err(e) = app.session.ruler_guide(vertical, &ev, on_canvas, app.view_info()) { app.status(e.to_string()); } diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 223c24d1f..728a38b09 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -126,7 +126,13 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo } else if let Some(p) = hover_tool && (pointer.is_moving() || mods_changed || !was_over || tool_switched || (context_changed && app.session.tool_wants_rehover())) { - let ev = PointerEvent { kind: PointerKind::Move, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + let ev = PointerEvent { + kind: PointerKind::Move, + pos: xf.to_doc(p), + mods: mods(m, space), + backquote: ui.input(|i| i.key_down(egui::Key::Backtick)), + pressure: 1.0, + }; dispatch(app, &ev, view); } // The pointer left the canvas (for a panel, or out of the window) or went under a floating @@ -265,7 +271,13 @@ fn begin_drag(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) { Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation, snaps: !space } } else { // Cmd with another tool drags with the selection tool used last (the session lends it). - let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, true) }; + let ev = PointerEvent { + kind: PointerKind::Down, + pos: xf.to_doc(p), + mods: mods(m, space), + backquote: ui.input(|i| i.key_down(egui::Key::Backtick)), + pressure: pen_pressure(ui, app, true), + }; dispatch(app, &ev, view); crate::document_drag::begin(app, ui.ctx(), ev.pos); Drag::Tool @@ -341,7 +353,13 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) Drag::Art if pointer.has_pointer() && on_canvas(ui, resp, p) => { // Back on the canvas: the move goes on from where it left, with the pointer. egui::DragAndDrop::clear_payload(ui.ctx()); - let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; + let ev = PointerEvent { + kind: PointerKind::Drag, + pos: xf.to_doc(p), + mods: mods(m, space), + backquote: ui.input(|i| i.key_down(egui::Key::Backtick)), + pressure: pen_pressure(ui, app, false), + }; dispatch(app, &ev, view); ui.data_mut(|dd| { dd.insert_temp(drag_id(), Drag::Tool); @@ -361,13 +379,20 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) // the last position only, so a costly drag preview runs once a frame. let moves = tool_frame_moves(ui, app); // A frame whose pointer ends where the last one did still moved in between. - let mods_changed = tool_drag_mods_changed(ui, mods(m, space)); + let backquote = ui.input(|i| i.key_down(egui::Key::Backtick)); + let mods_changed = tool_drag_mods_changed(ui, mods(m, space), backquote); if tool_drag_moved(ui, &xf, p, pointer) || mods_changed || !moves.is_empty() { for (at, force) in moves { let pressure = force.unwrap_or_else(|| pen_pressure(ui, app, false)); - dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(at), mods: mods(m, space), pressure }, view); + dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(at), mods: mods(m, space), backquote, pressure }, view); } - let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; + let ev = PointerEvent { + kind: PointerKind::Drag, + pos: xf.to_doc(p), + mods: mods(m, space), + backquote, + pressure: pen_pressure(ui, app, false), + }; dispatch(app, &ev, view); } // Time held (Twirl, Pucker and Bloat keep applying); a stalled frame counts @@ -431,10 +456,22 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po if let Some(last) = last_drag && last != xf.to_doc(p) { - let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; + let ev = PointerEvent { + kind: PointerKind::Drag, + pos: xf.to_doc(p), + mods: mods(m, space), + backquote: ui.input(|i| i.key_down(egui::Key::Backtick)), + pressure: pen_pressure(ui, app, false), + }; dispatch(app, &ev, view); } - let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; + let ev = PointerEvent { + kind: PointerKind::Up, + pos: xf.to_doc(p), + mods: mods(m, space), + backquote: ui.input(|i| i.key_down(egui::Key::Backtick)), + pressure: pen_pressure(ui, app, false), + }; dispatch(app, &ev, view); } Drag::Art if on_canvas(ui, resp, p) => { @@ -442,15 +479,45 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po egui::DragAndDrop::clear_payload(ui.ctx()); let pressure = pen_pressure(ui, app, false); let pos = xf.to_doc(p); - dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos, mods: mods(m, space), pressure }, view); - dispatch(app, &PointerEvent { kind: PointerKind::Up, pos, mods: mods(m, space), pressure }, view); + dispatch( + app, + &PointerEvent { + kind: PointerKind::Drag, + pos, + mods: mods(m, space), + backquote: ui.input(|i| i.key_down(egui::Key::Backtick)), + pressure, + }, + view, + ); + dispatch( + app, + &PointerEvent { + kind: PointerKind::Up, + pos, + mods: mods(m, space), + backquote: ui.input(|i| i.key_down(egui::Key::Backtick)), + pressure, + }, + view, + ); } Drag::Art => { // Released over a panel: the move goes back. (The app ends it before the panels // see the release, so a panel can take the art; this is the headless path.) // With no document open there is nothing to roll back; the tool still gets its up. let _ = app.session.cancel_interaction(); - dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); + dispatch( + app, + &PointerEvent { + kind: PointerKind::Up, + pos: Point::ZERO, + mods: Mods::default(), + backquote: ui.input(|i| i.key_down(egui::Key::Backtick)), + pressure: 1.0, + }, + view, + ); } Drag::Gesture | Drag::Present => {} Drag::Pan { .. } | Drag::RotateView { .. } => {} @@ -463,7 +530,13 @@ fn double_click(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) { if let Some(i) = artboard_name_at(app, ui, &xf, p) { begin_artboard_name_edit(ui, app, i); } else { - let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + let ev = PointerEvent { + kind: PointerKind::DoubleClick, + pos: xf.to_doc(p), + mods: mods(m, space), + backquote: ui.input(|i| i.key_down(egui::Key::Backtick)), + pressure: 1.0, + }; let before = app.session.tool_id().to_string(); dispatch(app, &ev, view); // A double-click on type switched a selection tool to the Type tool: the caret goes where diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 0c238693b..ed67da712 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -506,6 +506,26 @@ fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec out.textures_delta.clear(); } +#[test] +fn bucket_modifier_only_hover_updates_the_visible_cursor() { + use egui::{Event, Modifiers}; + use vectorcraft_tools::Cursor; + + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("livePaintBucket"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let at = app.canvas_rect.unwrap().center(); + let shown = |app: &mut VectorcraftApp, events| { + frame(app, &ctx, events); + app.canvas.hover_cursor.map(|(_, _, cursor)| cursor) + }; + assert_eq!(shown(&mut app, vec![Event::PointerMoved(at)]), Some(Cursor::LivePaintBucket)); + assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::ALT)]), Some(Cursor::Eyedropper)); + assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE)]), Some(Cursor::LivePaintBucket)); +} + /// The Image Trace panel's eye-button hold ends when the pointer is released, even when the /// panel stopped drawing while the button was held, so a later press on the canvas does not /// show the source image. @@ -580,7 +600,7 @@ fn a_click_activates_its_artboard_and_paste_in_place_goes_onto_it() { let view = app.view_info(); let at = boards[2].center(); for kind in [PointerKind::Down, PointerKind::Up] { - dispatch(&mut app, &PointerEvent { kind, pos: at, mods: Default::default(), pressure: 1.0 }, view); + dispatch(&mut app, &PointerEvent { kind, pos: at, mods: Default::default(), backquote: false, pressure: 1.0 }, view); } assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one"); let ids: Vec = app.run("edit.pasteInPlace", json!({})).unwrap()["ids"] @@ -613,7 +633,7 @@ fn a_shift_click_activates_its_artboard_and_align_to_artboard_aligns_to_it() { let view = app.view_info(); let shift = Mods { shift: true, ..Default::default() }; for kind in [PointerKind::Down, PointerKind::Up] { - dispatch(&mut app, &PointerEvent { kind, pos: boards[2].center(), mods: shift, pressure: 1.0 }, view); + dispatch(&mut app, &PointerEvent { kind, pos: boards[2].center(), mods: shift, backquote: false, pressure: 1.0 }, view); } assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one"); assert_eq!(app.session.active().unwrap().selection.objects, ids, "the selection stays"); @@ -668,7 +688,7 @@ fn the_active_artboard_is_outlined_in_black() { let view = app.view_info(); let shift = Mods { shift: true, ..Default::default() }; for kind in [PointerKind::Down, PointerKind::Up] { - dispatch(&mut app, &PointerEvent { kind, pos: at, mods: shift, pressure: 1.0 }, view); + dispatch(&mut app, &PointerEvent { kind, pos: at, mods: shift, backquote: false, pressure: 1.0 }, view); } assert_eq!(app.session.tool_options()["selected"], json!([2, 1])); assert_eq!(edges(&mut app), active(2), "Artboard 3 is the active one of the two selected"); @@ -2918,6 +2938,50 @@ fn mirror_axis_tracks_selection_options_and_view_without_editing_art() { assert!(axis(&mut app).is_empty()); } +#[test] +fn egui_backtick_drag_uses_key_state_at_release() { + use egui::Event; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let tile = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + app.run("object.pattern.make", json!({"ids": [tile], "name": "Dots", "width": 20, "height": 20})).unwrap(); + app.run("object.pattern.done", json!({})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"ids": [id], "swatch": "Dots"})).unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.select_tool("selection"); + app.ui.task_bar = false; + app.ui.view.rulers = false; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let from = xf.to_screen(Point::new(125.0, 125.0)); + let to = xf.to_screen(Point::new(145.0, 125.0)); + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let key = |pressed| Event::Key { key: egui::Key::Backtick, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + let node = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); + let before = node(&app); + frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, true)]); + frame(&mut app, &ctx, vec![key(true), Event::PointerMoved(to)]); + frame(&mut app, &ctx, vec![button(to, false)]); + let after = node(&app); + assert_eq!(after.geometric_bounds(), before.geometric_bounds(), "backtick release keeps geometry still"); + let vectorcraft_color::Paint::Pattern { xf, .. } = after.appearance.fill_paint() else { panic!("pattern fill expected") }; + assert!((xf.as_coeffs()[4] - 20.0).abs() < 0.001, "egui backtick reaches the active tool"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(node(&app), before, "one Undo restores the pattern transform"); + frame(&mut app, &ctx, vec![key(false)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, true)]); + frame(&mut app, &ctx, vec![key(true), Event::PointerMoved(to)]); + frame(&mut app, &ctx, vec![key(false)]); + frame(&mut app, &ctx, vec![button(to, false)]); + let ordinary = node(&app); + assert!( + (ordinary.geometric_bounds().unwrap().x0 - before.geometric_bounds().unwrap().x0 - 20.0).abs() < 0.001, + "key released before mouse up commits an ordinary move" + ); +} + #[test] fn mirror_axis_drag_uses_canvas_coordinates_in_a_zoomed_rotated_view() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -6091,6 +6155,51 @@ fn a_modifier_changed_mid_scale_applies_with_the_pointer_held_still() { assert!(app.session.active().unwrap().interaction.is_none(), "committed"); } +/// Backtick switches a moved Selection drag's live preview while the pointer stays still. +#[test] +fn backtick_changed_mid_selection_drag_applies_with_the_pointer_held_still() { + use egui::Event; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let tile = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + app.run("object.pattern.make", json!({"ids": [tile], "name": "Dots", "width": 20, "height": 20})).unwrap(); + app.run("object.pattern.done", json!({})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"ids": [id], "swatch": "Dots"})).unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.select_tool("selection"); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.ui.view.smart_guides = false; + app.ui.view.snap_to_point = false; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let from = xf.to_screen(Point::new(125.0, 125.0)); + let to = xf.to_screen(Point::new(145.0, 125.0)); + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let key = |pressed| Event::Key { key: egui::Key::Backtick, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + let node = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); + let pattern_x = |node: &vectorcraft_doc::Node| { + let vectorcraft_color::Paint::Pattern { xf, .. } = node.appearance.fill_paint() else { panic!("pattern fill expected") }; + xf.as_coeffs()[4] + }; + let before = node(&app); + frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, true)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); + let moved = node(&app); + assert!((moved.geometric_bounds().unwrap().x0 - before.geometric_bounds().unwrap().x0 - 20.0).abs() < 0.001); + frame(&mut app, &ctx, vec![key(true)]); + let pattern_only = node(&app); + assert_eq!(pattern_only.geometric_bounds(), before.geometric_bounds(), "Backtick restores geometry in the live preview"); + assert!((pattern_x(&pattern_only) - pattern_x(&before) - 20.0).abs() < 0.001, "Backtick moves the pattern in the live preview"); + frame(&mut app, &ctx, vec![key(false)]); + let ordinary = node(&app); + assert_eq!(ordinary.geometric_bounds(), moved.geometric_bounds(), "releasing Backtick restores the ordinary move in the live preview"); + assert!((pattern_x(&ordinary) - pattern_x(&before)).abs() < 0.001, "the ordinary move restores the pattern matrix"); + frame(&mut app, &ctx, vec![button(to, false)]); +} + /// #834 review: a press held still is not a drag. Before the press has dragged, the first held /// frame without a delta at the press's own position sends nothing, so a click stays a click; /// the first move after it does. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 68bba9ade..a30075713 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -9,7 +9,7 @@ //! - `ui.menu.list`: flattened menu tree //! - `ui.contextMenu.list`: the canvas context menu for the current selection, flattened //! - `ui.tool.select {tool}`, `ui.tool.list` -//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen"}], mods?}`: +//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, backquote?, space?: "doc"|"screen"}], mods?, backquote?}`: //! drive the active tool through the same path as the mouse //! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: synthetic keyboard input //! - `ui.move {x, y}` / `ui.click {x, y, button?, count?, shift?…}` / `ui.drag {x, y, toX, toY, steps?}`: @@ -528,7 +528,8 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe vectorcraft_geom::Point::new(x, y) }; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); - crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, pressure: PointerEvent::json_pressure(e) }, view); + let backquote = e.get("backquote").and_then(Value::as_bool).unwrap_or_else(|| p.get("backquote").and_then(Value::as_bool).unwrap_or(false)); + crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, backquote, pressure: PointerEvent::json_pressure(e) }, view); let hold = PointerEvent::json_hold(e); if hold > 0.0 { let r = app.session.tool_tick(hold, view); diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index f24c958c4..3e27607f3 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -227,6 +227,37 @@ fn render_takes_the_artboard_asked_for() { assert!(request("ui.render", json!({"artboard": 2}))["error"].is_string()); } +#[test] +fn synthetic_pointer_backquote_keeps_solid_object_in_place() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + let tile = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + app.run("object.pattern.make", json!({"ids": [tile], "name": "Dots", "width": 20, "height": 20})).unwrap(); + app.run("object.pattern.done", json!({})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"ids": [id], "swatch": "Dots"})).unwrap(); + let solid = app.run("shape.rectangle", json!({"x": 220, "y": 100, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"ids": [solid], "color": "#3366cc"})).unwrap(); + app.run("select.set", json!({"ids": [id, solid]})).unwrap(); + let before = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); + let before_solid = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(solid)).unwrap().clone(); + let r = input_request( + &mut app, + "ui.pointer", + json!({"events": [ + {"kind": "down", "x": 125, "y": 125}, + {"kind": "drag", "x": 145, "y": 125}, + {"kind": "up", "x": 145, "y": 125} + ], "backquote": true}), + ); + assert_eq!(r["ok"], true, "{r}"); + let after = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(); + assert_eq!(after.geometric_bounds(), before.geometric_bounds()); + assert_eq!(app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(solid)).unwrap(), &before_solid); + let vectorcraft_color::Paint::Pattern { xf, .. } = after.appearance.fill_paint() else { panic!("pattern fill expected") }; + assert_eq!(xf.as_coeffs()[4], 20.0, "synthetic backquote drag changes pattern paint"); +} + /// `ui.pointer` replies with the key object: a click on one of two selected objects makes it /// the key, and a click on the key again lets it go. #[test] diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index 530ac8c95..55312c352 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -440,7 +440,7 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) { /// Every cursor with a glyph: its own, drawn here, or the one it borrows. #[cfg(test)] -const GLYPHS: [Cursor; 86] = [ +const GLYPHS: [Cursor; 89] = [ Cursor::Hand, Cursor::HandGrab, Cursor::ZoomIn, @@ -526,6 +526,9 @@ const GLYPHS: [Cursor; 86] = [ Cursor::ExtendPath, Cursor::MeasureInspect, Cursor::ParametricShape, + Cursor::CornerTool, + Cursor::SlideAnchor, + Cursor::SharedEdge, Cursor::SmartPen, ]; diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 6fc6203de..1b96fd072 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -329,7 +329,7 @@ fn choices(effect: &str, key: &str) -> Option { ("stylize.dropShadow", "shade") => Some(SHADES), ("path.offsetPath", "joins") => Some(&[("Miter", "miter"), ("Round", "round"), ("Bevel", "bevel")]), ("distort.roughen" | "distort.zigZag", "points") => Some(&[("Smooth", "smooth"), ("Corner", "corner")]), - _ => None, + _ => crate::addons::pathtools::effect_choices(effect, key), } } diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index ce456fb26..ce0177750 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -647,6 +647,17 @@ pub(crate) fn humanize(k: &str) -> String { "Sy" => "Vertical %".into(), "Radius1" => "Radius 1".into(), "Radius2" => "Radius 2".into(), + "Dash1" => "Dash 1:".into(), + "Dash2" => "Dash 2:".into(), + "Dash3" => "Dash 3:".into(), + "Dash4" => "Dash 4:".into(), + "Dash5" => "Dash 5:".into(), + "Gap1" => "Gap 1:".into(), + "Gap2" => "Gap 2:".into(), + "Gap3" => "Gap 3:".into(), + "Gap4" => "Gap 4:".into(), + "Gap5" => "Gap 5:".into(), + "Align Corners" => "Align to Corners and Ends:".into(), "Max Radius" => "Max. Radius:".into(), "Blur Mode" => "Mode:".into(), "Channel1" => "Channel 1:".into(), diff --git a/crates/ui-egui/src/dialogs/gradient_stop.rs b/crates/ui-egui/src/dialogs/gradient_stop.rs index 42e66f258..2236ef187 100644 --- a/crates/ui-egui/src/dialogs/gradient_stop.rs +++ b/crates/ui-egui/src/dialogs/gradient_stop.rs @@ -353,7 +353,11 @@ mod tests { let mut app = app(); let view = app.view_info(); let chip = Point::new(150.0, 150.0 + 10.0 / view.zoom); - crate::canvas::dispatch(&mut app, &PointerEvent { kind: PointerKind::DoubleClick, pos: chip, mods: Default::default(), pressure: 1.0 }, view); + crate::canvas::dispatch( + &mut app, + &PointerEvent { kind: PointerKind::DoubleClick, pos: chip, mods: Default::default(), backquote: false, pressure: 1.0 }, + view, + ); let d = app.ui.dialog.clone().expect("the popover opened"); assert_eq!((d.kind.as_str(), d.fields["index"].clone(), d.str("tab")), ("gradientStop", json!(1), "color".into())); assert_eq!(app.session.selected_stop(), Some(1)); @@ -453,7 +457,11 @@ mod tests { let (p, i) = point(&app); // Double-clicking the point opens the popover beside it. let view = app.view_info(); - crate::canvas::dispatch(&mut app, &PointerEvent { kind: PointerKind::DoubleClick, pos: p.at, mods: Default::default(), pressure: 1.0 }, view); + crate::canvas::dispatch( + &mut app, + &PointerEvent { kind: PointerKind::DoubleClick, pos: p.at, mods: Default::default(), backquote: false, pressure: 1.0 }, + view, + ); let d = app.ui.dialog.clone().expect("the popover opened"); assert_eq!((d.kind.as_str(), d.fields["index"].clone()), ("gradientStop", json!(i))); frame(&mut app, vec![]); diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs index bf2aed988..38f469e30 100644 --- a/crates/ui-egui/src/dialogs/tests_screens.rs +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -113,7 +113,7 @@ fn opens_with_the_artboards_the_artboard_tool_selected_and_exports_only_them() { let click = |app: &mut VectorcraftApp, i: usize, shift: bool| { let mods = Mods { shift, ..Default::default() }; for kind in [PointerKind::Down, PointerKind::Up] { - crate::canvas::dispatch(app, &PointerEvent { kind, pos: boards[i].center(), mods, pressure: 1.0 }, view); + crate::canvas::dispatch(app, &PointerEvent { kind, pos: boards[i].center(), mods, backquote: false, pressure: 1.0 }, view); } }; // A click on artboard 2 and a Shift-click on artboard 3 select those two. The dialog opens with diff --git a/crates/ui-egui/src/edit_toolbar/tests.rs b/crates/ui-egui/src/edit_toolbar/tests.rs index 7d547f240..4e08abe6f 100644 --- a/crates/ui-egui/src/edit_toolbar/tests.rs +++ b/crates/ui-egui/src/edit_toolbar/tests.rs @@ -379,7 +379,10 @@ fn drop_targets_follow_the_buttons_edges() { fn addon_tools_are_in_the_drawer_not_the_toolbars() { let mut app = app(); let addon: Vec<&str> = vectorcraft_tools::addons::GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect(); - assert_eq!(addon, ["anchorEraserBrush", "extendPath", "measureInspect", "parametricShape", "smartPen"]); + assert_eq!( + addon, + ["anchorEraserBrush", "extendPath", "measureInspect", "parametricShape", "cornerTool", "slideAnchor", "sharedEdge", "smartPen"] + ); for advanced in [false, true] { if advanced { app.run("window.toolbarAdvanced", json!({})).unwrap(); @@ -389,7 +392,7 @@ fn addon_tools_are_in_the_drawer_not_the_toolbars() { let listed = |id: &str| { sections().iter().find(|(_, tools)| tools.iter().any(|t| t.id == id)).map(|(h, tools)| (*h, tools.iter().map(|t| t.id).collect::>())) }; - assert_eq!(listed("extendPath"), Some(("Path Precision Tools", addon[..4].to_vec()))); + assert_eq!(listed("extendPath"), Some(("Path Precision Tools", addon[..7].to_vec()))); assert_eq!(listed("smartPen"), Some(("Smart Pen", vec!["smartPen"]))); for tool in ["extendPath", "smartPen"] { app.run("toolbar.addTool", json!({"tool": tool})).unwrap(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ae80e9422..35b67faa3 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -499,7 +499,7 @@ font-technology Variable Променливи Collect in New Layer Събиране в нов слой Collect Links in a Separate Folder Събиране на връзките в отделна папка Color Цвят - Color (,) Цвят (,) + Color (, or <) Цвят (, или <) Color Adjustments Цветови корекции Color bars Цветови скали Color blindness – Deuteranopia-type Цветна слепота – тип девтеранопия @@ -1240,7 +1240,7 @@ font-technology Variable Променливи GPL palettes keep solid colours only, as RGB. Палитрите GPL запазват само плътни цветове като RGB. GPU Performance Производителност на графичния процесор Gradient Градиент - Gradient (.) Градиент (.) + Gradient (. or >) Градиент (. или >) Gradient across stroke Градиент напречно на щриха Gradient along stroke Градиент по дължината на щриха Gradient and Mesh Resolution: Разделителна способност на градиент и мрежа: @@ -5776,3 +5776,111 @@ anchor Points: Точки: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` задайте `inkLimit`, `removeWhiteOverprint` или `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Корекциите на мастилата за печат работят върху CMYK документи: първо изберете Файл › Цветен режим на документа › CMYK цвят @msg `{key}` must be 0..{max} `{key}` трябва да е 0..{max} +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Инструмент „Ъгли“ + Slide Anchor Along Path Плъзгане на опорна точка по пътя + Set Corners Задаване на ъгли + Remove Corners Премахване на ъгли + Corners selected: {count} Избрани ъгли: {count} + Apply to Selected Anchors Прилагане към избраните опорни точки +@msg `radius` must be from 0 to 1000000 pt `radius` трябва да е от 0 до 1000000 pt +@msg `t` must be a number from -0.98 to 0.98 `t` трябва да е число от -0.98 до 0.98 +@msg choose anchors where two sides meet at an angle: only those take a corner изберете опорни точки, където две страни се срещат под ъгъл: само те могат да имат ъгъл +@msg give `anchor`, the index of the anchor to slide посочете `anchor`, индекса на опорната точка за плъзгане +@msg that anchor can't slide: an end of an open path has nowhere to go тази опорна точка не може да се плъзга: краят на отворен път няма накъде да отиде +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Инструмент „Сливане на общ ръб“ + Merge Shared Edge Сливане на общ ръб + Keep Shared Points Together Общите точки да остават заедно + Shared Edges Общи ръбове + Coincidence: Съвпадение: +@msg `a` and `b` must be two different paths `a` и `b` трябва да са два различни пътя +@msg `diameter` must be from 0.25 to 200 pt `diameter` трябва да е от 0.25 до 200 pt +@msg `tolerance` must be from 0 to 3 pt `tolerance` трябва да е от 0 до 3 pt +@msg `follow` must be "average", "a" or "b" `follow` трябва да е "average", "a" или "b" +@msg the paths have nothing to merge there пътищата нямат какво да се слеят тук + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Разделяне на текст + Combine Point Text Обединяване на точков текст + Remove Breaks Премахване на прекъсвания + Split Text at Cursor Разделяне на текст при курсора + Set Alignment, Keep Position Задаване на подравняване със запазване на позицията + Reset Text Transform Нулиране на трансформацията на текста + Replace Missing Fonts… Замяна на липсващите шрифтове… + Replace Missing Fonts Замяна на липсващите шрифтове + Remove Line Breaks Премахване на прекъсванията на редове + Remove Paragraph Breaks Премахване на прекъсванията на абзаци + Break Text into Lines Разделяне на текста на редове + Break Text into Paragraphs Разделяне на текста на абзаци + Break Text into Words Разделяне на текста на думи + Break Text into Glyphs Разделяне на текста на глифи + Reset Text Transform and Scale Нулиране на трансформацията и мащаба на текста + Set Alignment, Keep Position: Подравняване със запазване на позицията: + Break Text into: Разделяне на текста на: + Split at Cursor Разделяне при курсора + Reset Transform Нулиране на трансформацията + Shift-click: break into paragraphs Shift-щракване: разделяне на абзаци + Alt-click: reset the horizontal scale too Alt-щракване: нулиране и на хоризонталния мащаб +breakText Lines Редове +breakText Words Думи +breakText Glyphs Глифи +@msg Click in type with a Type tool to split it Щракнете в текст с инструмента „Текст“, за да го разделите +@msg Too many type objects to change at once Твърде много текстови обекти за промяна наведнъж +@msg Too many pieces to make at once Твърде много части за създаване наведнъж +@msg Select horizontal point or area type whose text fits Изберете хоризонтален точков текст или текст в зона, чийто текст се побира +@msg Nothing to split at the cursor При курсора няма какво да се раздели +@msg Select two or more one-line point type objects on one baseline to combine Изберете два или повече едноредови обекта с точков текст на една базова линия, за да ги обедините +@msg No line breaks to remove Няма прекъсвания на редове за премахване +@msg No paragraph breaks to remove Няма прекъсвания на абзаци за премахване +@msg Too many breaks to remove at once Твърде много прекъсвания за премахване наведнъж +@msg Select point type Изберете точков текст +@msg The point type is aligned that way already Точковият текст вече е подравнен така +@msg The point type has no transform to reset Точковият текст няма трансформация за нулиране +@msg No fonts are missing Няма липсващи шрифтове +@msg the replacement font is not available заместващият шрифт не е наличен +@msg missing `align` липсва `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` трябва да е lines, paragraphs, words или glyphs, а не {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` трябва да е "line" или "paragraph", а не {other} +@msg `align` must be "left", "center" or "right", not {other} `align` трябва да е "left", "center" или "right", а не {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Mode Режим + Path Effects Ефекти на пътя + Reverse Direction Обръщане на посоката + Align to Corners and Ends Подравняване към ъглите и краищата + Count Брой + Max Count Максимален брой + Start Length Дължина в началото + Start Percent Процент в началото + Start Shape Форма в началото + End Length Дължина в края + End Percent Процент в края + End Shape Форма в края + By Count По брой + By Distance По разстояние + Both И двете + Shorter Than Length По-къси от дължината + Dashes to Geometry Тирета в геометрия + Dashes to Geometry… Тирета в геометрия… + Add Anchors Добавяне на опорни точки + Add Anchors… Добавяне на опорни точки… + Smooth Anchors Заглаждане на опорните точки + Smooth Anchors… Заглаждане на опорните точки… + Simplify Anchors Опростяване на опорните точки + Simplify Anchors… Опростяване на опорните точки… + Split at Anchors Разделяне в опорните точки + Split at Anchors… Разделяне в опорните точки… + Extend Path… Удължаване на път… + Close Path… Затваряне на пътя… + Retract Handles… Прибиране на дръжките… + Dash 1 Тире 1 + Gap 1 Разстояние 1 + Dash 2 Тире 2 + Gap 2 Разстояние 2 + Dash 3 Тире 3 + Gap 3 Разстояние 3 + Dash 4 Тире 4 + Gap 4 Разстояние 4 + Dash 5 Тире 5 + Gap 5 Разстояние 5 diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index c855f1b57..aef4f99f8 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5856,3 +5856,69 @@ swatch Yellow Green Žuto-zelena Position: Položaj: Font Size: Veličina fonta: shape Open Otvoren + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Retract Handles Uvuci ručke + Curved Zakrivljeno + Path Effects Efekti putanje + Reverse Direction Obrni smjer + Align to Corners and Ends Poravnaj s uglovima i krajevima + Count Broj + Max Count Najveći broj + Start Length Dužina na početku + Start Percent Postotak na početku + Start Shape Oblik na početku + End Length Dužina na kraju + End Percent Postotak na kraju + End Shape Oblik na kraju + By Count Po broju + By Distance Po udaljenosti + Both Oba + Shorter Than Length Kraće od dužine + Dashes to Geometry Crtice u geometriju + Dashes to Geometry… Crtice u geometriju… + Add Anchors Dodaj sidra + Add Anchors… Dodaj sidra… + Smooth Anchors Zagladi sidra + Smooth Anchors… Zagladi sidra… + Simplify Anchors Pojednostavi sidra + Simplify Anchors… Pojednostavi sidra… + Split at Anchors Razdvoji na sidrima + Split at Anchors… Razdvoji na sidrima… + Extend Path… Produži putanju… + Close Path… Zatvori putanju… + Retract Handles… Uvuci ručke… + Dash 1 Crtica 1 + Gap 1 Razmak 1 + Dash 2 Crtica 2 + Gap 2 Razmak 2 + Dash 3 Crtica 3 + Gap 3 Razmak 3 + Dash 4 Crtica 4 + Gap 4 Razmak 4 + Dash 5 Crtica 5 + Gap 5 Razmak 5 +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Alat za uglove + Slide Anchor Along Path Klizanje sidra po putanji + Set Corners Postavi uglove + Remove Corners Ukloni uglove + Corners selected: {count} Odabrani uglovi: {count} + Apply to Selected Anchors Primijeni na odabrana sidra +@msg `radius` must be from 0 to 1000000 pt `radius` mora biti od 0 do 1000000 pt +@msg `t` must be a number from -0.98 to 0.98 `t` mora biti broj od -0.98 do 0.98 +@msg choose anchors where two sides meet at an angle: only those take a corner odaberite sidra gdje se dvije stranice sastaju pod uglom: samo ona primaju ugao +@msg give `anchor`, the index of the anchor to slide navedite `anchor`, indeks sidra koje klizi +@msg that anchor can't slide: an end of an open path has nowhere to go to sidro ne može kliziti: kraj otvorene putanje nema kuda +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Alat za spajanje zajedničke ivice + Merge Shared Edge Spoji zajedničku ivicu + Keep Shared Points Together Drži zajedničke tačke zajedno + Shared Edges Zajedničke ivice + Coincidence: Podudarnost: +@msg `a` and `b` must be two different paths `a` i `b` moraju biti dvije različite putanje +@msg `diameter` must be from 0.25 to 200 pt `diameter` mora biti od 0.25 do 200 pt +@msg `tolerance` must be from 0 to 3 pt `tolerance` mora biti od 0 do 3 pt +@msg `follow` must be "average", "a" or "b" `follow` mora biti "average", "a" ili "b" +@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 diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 410f978f9..7d367ce8f 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -712,7 +712,7 @@ Collect in New Layer Recull en una capa nova Collect Links in a Separate Folder Recull els enllaços en una carpeta a part Color Color - Color (,) Color (,) + Color (, or <) Color (, o <) Color Adjustments Ajustos de color Color bars Barres de color Color blindness – Deuteranopia-type Daltonisme – tipus deuteranòpsia @@ -1695,7 +1695,7 @@ GPU Performance Rendiment de la GPU Grab bag Calaix de sastre Gradient Degradat - Gradient (.) Degradat (.) + Gradient (. or >) Degradat (. o >) Gradient across stroke Degradat transversal al traç Gradient along stroke Degradat al llarg del traç Gradient and Mesh Resolution: Resolució de degradat i malla: @@ -5878,3 +5878,110 @@ anchor Points: Punts: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` indiqueu `inkLimit`, `removeWhiteOverprint` o `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Les correccions de tinta per a impressió funcionen en documents CMYK: trieu primer Fitxer › Mode de color del document › Color CMYK @msg `{key}` must be 0..{max} `{key}` ha de ser 0..{max} +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Eina de cantonades + Slide Anchor Along Path Llisca l’ancoratge pel traçat + Set Corners Defineix les cantonades + Remove Corners Elimina les cantonades + Corners selected: {count} Cantonades seleccionades: {count} + Apply to Selected Anchors Aplica als punts d’ancoratge seleccionats +@msg `radius` must be from 0 to 1000000 pt `radius` ha de ser de 0 a 1000000 pt +@msg `t` must be a number from -0.98 to 0.98 `t` ha de ser un nombre de -0.98 a 0.98 +@msg choose anchors where two sides meet at an angle: only those take a corner trieu punts d’ancoratge on dos costats es troben formant un angle: només aquests admeten una cantonada +@msg give `anchor`, the index of the anchor to slide indiqueu `anchor`, l’índex del punt d’ancoratge que voleu lliscar +@msg that anchor can't slide: an end of an open path has nowhere to go aquest punt d’ancoratge no pot lliscar: l’extrem d’un traçat obert no té on anar +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Eina de fusió de vores compartides + Merge Shared Edge Fusiona la vora compartida + Keep Shared Points Together Mantén junts els punts compartits + Shared Edges Vores compartides + Coincidence: Coincidència: +@msg `a` and `b` must be two different paths `a` i `b` han de ser dos traçats diferents +@msg `diameter` must be from 0.25 to 200 pt `diameter` ha de ser de 0.25 a 200 pt +@msg `tolerance` must be from 0 to 3 pt `tolerance` ha de ser de 0 a 3 pt +@msg `follow` must be "average", "a" or "b" `follow` ha de ser "average", "a" o "b" +@msg the paths have nothing to merge there els traçats no tenen res a fusionar aquí + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Divideix el text + Combine Point Text Combina el text de punt + Remove Breaks Elimina els salts + Split Text at Cursor Divideix el text al cursor + Set Alignment, Keep Position Defineix l’alineació i manté la posició + Reset Text Transform Restableix la transformació del text + Replace Missing Fonts… Reemplaça els tipus de lletra que falten… + Replace Missing Fonts Reemplaça els tipus de lletra que falten + Remove Line Breaks Elimina els salts de línia + Remove Paragraph Breaks Elimina els salts de paràgraf + Break Text into Lines Divideix el text en línies + Break Text into Paragraphs Divideix el text en paràgrafs + Break Text into Words Divideix el text en paraules + Break Text into Glyphs Divideix el text en glifs + Reset Text Transform and Scale Restableix la transformació i l’escala del text + Set Alignment, Keep Position: Defineix l’alineació i manté la posició: + Break Text into: Divideix el text en: + Split at Cursor Divideix al cursor + Reset Transform Restableix la transformació + Shift-click: break into paragraphs Maj+clic: divideix en paràgrafs + Alt-click: reset the horizontal scale too Alt-clic: restableix també l’escala horitzontal +breakText Lines Línies +breakText Words Paraules +breakText Glyphs Glifs +@msg Click in type with a Type tool to split it Feu clic en un text amb una eina de text per dividir-lo +@msg Too many type objects to change at once Massa objectes de text per canviar-los alhora +@msg Too many pieces to make at once Massa peces per crear-les alhora +@msg Select horizontal point or area type whose text fits Seleccioneu text de punt o d’àrea horitzontal el text del qual hi càpiga +@msg Nothing to split at the cursor No hi ha res a dividir al cursor +@msg Select two or more one-line point type objects on one baseline to combine Seleccioneu dos o més objectes de text de punt d’una línia sobre una mateixa línia de base per combinar-los +@msg No line breaks to remove No hi ha salts de línia per eliminar +@msg No paragraph breaks to remove No hi ha salts de paràgraf per eliminar +@msg Too many breaks to remove at once Massa salts per eliminar-los alhora +@msg Select point type Seleccioneu text de punt +@msg The point type is aligned that way already El text de punt ja està alineat així +@msg The point type has no transform to reset El text de punt no té cap transformació per restablir +@msg No fonts are missing No falta cap tipus de lletra +@msg the replacement font is not available el tipus de lletra de reemplaçament no està disponible +@msg missing `align` falta `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` ha de ser lines, paragraphs, words o glyphs, no {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` ha de ser "line" o "paragraph", no {other} +@msg `align` must be "left", "center" or "right", not {other} `align` ha de ser "left", "center" o "right", no {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects Efectes de traçat + Reverse Direction Inverteix la direcció + Align to Corners and Ends Alinea a les cantonades i als extrems + Count Nombre + Max Count Nombre màxim + Start Length Longitud a l’inici + Start Percent Percentatge a l’inici + Start Shape Forma a l’inici + End Length Longitud al final + End Percent Percentatge al final + End Shape Forma al final + By Count Per nombre + By Distance Per distància + Both Tots dos + Shorter Than Length Més curts que la longitud + Dashes to Geometry Traços a geometria + Dashes to Geometry… Traços a geometria… + Add Anchors Afegeix punts d’ancoratge + Add Anchors… Afegeix punts d’ancoratge… + Smooth Anchors Suavitza els punts d’ancoratge + Smooth Anchors… Suavitza els punts d’ancoratge… + Simplify Anchors Simplifica els punts d’ancoratge + Simplify Anchors… Simplifica els punts d’ancoratge… + Split at Anchors Divideix als punts d’ancoratge + Split at Anchors… Divideix als punts d’ancoratge… + Extend Path… Allarga el traçat… + Close Path… Tanca el traçat… + Retract Handles… Retrau les nanses… + Dash 1 Guió 1 + Gap 1 Separació 1 + Dash 2 Guió 2 + Gap 2 Separació 2 + Dash 3 Guió 3 + Gap 3 Separació 3 + Dash 4 Guió 4 + Gap 4 Separació 4 + Dash 5 Guió 5 + Gap 5 Separació 5 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 2cfd5b0db..852715eec 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1209,12 +1209,12 @@ lighting Light Světlo to set spacing and orientation nastaví rozestupy a orientaci Apply Použít Change Screen Mode (F) Změnit režim obrazovky (F) - Color (,) Barva (,) + Color (, or <) Barva (, nebo <) Draw Behind Kreslit za Draw Inside Kreslit dovnitř Draw Normal Kreslit normálně Expand: Rozložit: - Gradient (.) Přechod (.) + Gradient (. or >) Přechod (. nebo >) H: V: Modify Úpravy Navigate Pohled @@ -5887,3 +5887,110 @@ anchor Points: Body: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` zadejte `inkLimit`, `removeWhiteOverprint` nebo `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Opravy tiskových barev fungují v dokumentech CMYK: nejprve zvolte Soubor › Barevný režim dokumentu › Barvy CMYK @msg `{key}` must be 0..{max} `{key}` musí být 0..{max} +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Nástroj Rohy + Slide Anchor Along Path Posunout kotevní bod po cestě + Set Corners Nastavit rohy + Remove Corners Odstranit rohy + Corners selected: {count} Vybrané rohy: {count} + Apply to Selected Anchors Použít na vybrané kotevní body +@msg `radius` must be from 0 to 1000000 pt `radius` musí být od 0 do 1000000 pt +@msg `t` must be a number from -0.98 to 0.98 `t` musí být číslo od -0.98 do 0.98 +@msg choose anchors where two sides meet at an angle: only those take a corner vyberte kotevní body, kde se dvě strany setkávají pod úhlem: jen ty mohou mít roh +@msg give `anchor`, the index of the anchor to slide zadejte `anchor`, index kotevního bodu k posunutí +@msg that anchor can't slide: an end of an open path has nowhere to go tento kotevní bod nelze posunout: konec otevřené cesty nemá kam jít +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Nástroj Sloučení sdílené hrany + Merge Shared Edge Sloučit sdílenou hranu + Keep Shared Points Together Držet sdílené body pohromadě + Shared Edges Sdílené hrany + Coincidence: Shoda: +@msg `a` and `b` must be two different paths `a` a `b` musí být dvě různé cesty +@msg `diameter` must be from 0.25 to 200 pt `diameter` musí být od 0.25 do 200 pt +@msg `tolerance` must be from 0 to 3 pt `tolerance` musí být od 0 do 3 pt +@msg `follow` must be "average", "a" or "b" `follow` musí být "average", "a" nebo "b" +@msg the paths have nothing to merge there cesty zde nemají co sloučit + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Rozdělit text + Combine Point Text Sloučit bodový text + Remove Breaks Odstranit zalomení + Split Text at Cursor Rozdělit text u kurzoru + Set Alignment, Keep Position Nastavit zarovnání, zachovat polohu + Reset Text Transform Obnovit transformaci textu + Replace Missing Fonts… Nahradit chybějící písma… + Replace Missing Fonts Nahradit chybějící písma + Remove Line Breaks Odstranit zalomení řádků + Remove Paragraph Breaks Odstranit zalomení odstavců + Break Text into Lines Rozdělit text na řádky + Break Text into Paragraphs Rozdělit text na odstavce + Break Text into Words Rozdělit text na slova + Break Text into Glyphs Rozdělit text na glyfy + Reset Text Transform and Scale Obnovit transformaci a měřítko textu + Set Alignment, Keep Position: Nastavit zarovnání, zachovat polohu: + Break Text into: Rozdělit text na: + Split at Cursor Rozdělit u kurzoru + Reset Transform Obnovit transformaci + Shift-click: break into paragraphs Shift+kliknutí: rozdělit na odstavce + Alt-click: reset the horizontal scale too Alt+kliknutí: obnovit i vodorovné měřítko +breakText Lines Řádky +breakText Words Slova +breakText Glyphs Glyfy +@msg Click in type with a Type tool to split it Klikněte do textu nástrojem Text, abyste jej rozdělili +@msg Too many type objects to change at once Příliš mnoho textových objektů ke změně najednou +@msg Too many pieces to make at once Příliš mnoho částí k vytvoření najednou +@msg Select horizontal point or area type whose text fits Vyberte vodorovný bodový text nebo text v oblasti, jehož text se vejde +@msg Nothing to split at the cursor U kurzoru není co rozdělit +@msg Select two or more one-line point type objects on one baseline to combine Vyberte dva nebo více jednořádkových objektů bodového textu na jednom účaří, abyste je sloučili +@msg No line breaks to remove Žádná zalomení řádků k odstranění +@msg No paragraph breaks to remove Žádná zalomení odstavců k odstranění +@msg Too many breaks to remove at once Příliš mnoho zalomení k odstranění najednou +@msg Select point type Vyberte bodový text +@msg The point type is aligned that way already Bodový text je již takto zarovnán +@msg The point type has no transform to reset Bodový text nemá žádnou transformaci k obnovení +@msg No fonts are missing Žádná písma nechybějí +@msg the replacement font is not available náhradní písmo není k dispozici +@msg missing `align` chybí `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` musí být lines, paragraphs, words nebo glyphs, ne {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` musí být "line" nebo "paragraph", ne {other} +@msg `align` must be "left", "center" or "right", not {other} `align` musí být "left", "center" nebo "right", ne {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects Efekty cesty + Reverse Direction Obrátit směr + Align to Corners and Ends Zarovnat k rohům a koncům + Count Počet + Max Count Maximální počet + Start Length Délka na začátku + Start Percent Procenta na začátku + Start Shape Tvar na začátku + End Length Délka na konci + End Percent Procenta na konci + End Shape Tvar na konci + By Count Podle počtu + By Distance Podle vzdálenosti + Both Obojí + Shorter Than Length Kratší než délka + Dashes to Geometry Čárky na geometrii + Dashes to Geometry… Čárky na geometrii… + Add Anchors Přidat kotevní body + Add Anchors… Přidat kotevní body… + Smooth Anchors Vyhladit kotevní body + Smooth Anchors… Vyhladit kotevní body… + Simplify Anchors Zjednodušit kotevní body + Simplify Anchors… Zjednodušit kotevní body… + Split at Anchors Rozdělit v kotevních bodech + Split at Anchors… Rozdělit v kotevních bodech… + Extend Path… Prodloužit cestu… + Close Path… Uzavřít cestu… + Retract Handles… Zasunout úchyty… + Dash 1 Čárka 1 + Gap 1 Mezera 1 + Dash 2 Čárka 2 + Gap 2 Mezera 2 + Dash 3 Čárka 3 + Gap 3 Mezera 3 + Dash 4 Čárka 4 + Gap 4 Mezera 4 + Dash 5 Čárka 5 + Gap 5 Mezera 5 diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index a625dc65a..526dfd26b 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1127,12 +1127,12 @@ lighting Light Lys to set spacing and orientation for at angive afstand og orientering Apply Anvend Change Screen Mode (F) Skift skærmtilstand (F) - Color (,) Farve (,) + Color (, or <) Farve (, eller <) Draw Behind Tegn bagved Draw Inside Tegn indeni Draw Normal Tegn normalt Expand: Udvid: - Gradient (.) Gradient (.) + Gradient (. or >) Gradient (. eller >) H: H: Modify Ændr Navigate Navigér @@ -5791,3 +5791,110 @@ anchor Points: Punkter: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` angiv `inkLimit`, `removeWhiteOverprint` eller `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Trykfarverettelser virker på CMYK-dokumenter: vælg først Filer › Dokumentets farvetilstand › CMYK-farve @msg `{key}` must be 0..{max} `{key}` skal være 0..{max} +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Hjørneværktøj + Slide Anchor Along Path Skub ankerpunkt langs sti + Set Corners Angiv hjørner + Remove Corners Fjern hjørner + Corners selected: {count} Valgte hjørner: {count} + Apply to Selected Anchors Anvend på valgte ankerpunkter +@msg `radius` must be from 0 to 1000000 pt `radius` skal være fra 0 til 1000000 pt +@msg `t` must be a number from -0.98 to 0.98 `t` skal være et tal fra -0.98 til 0.98 +@msg choose anchors where two sides meet at an angle: only those take a corner vælg ankerpunkter, hvor to sider mødes i en vinkel: kun de kan få et hjørne +@msg give `anchor`, the index of the anchor to slide angiv `anchor`, indekset for det ankerpunkt, der skal skubbes +@msg that anchor can't slide: an end of an open path has nowhere to go det ankerpunkt kan ikke skubbes: enden af en åben sti har ingen steder at gå hen +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Værktøj til fletning af fælles kant + Merge Shared Edge Flet fælles kant + Keep Shared Points Together Hold fælles punkter sammen + Shared Edges Fælles kanter + Coincidence: Sammenfald: +@msg `a` and `b` must be two different paths `a` og `b` skal være to forskellige stier +@msg `diameter` must be from 0.25 to 200 pt `diameter` skal være fra 0.25 til 200 pt +@msg `tolerance` must be from 0 to 3 pt `tolerance` skal være fra 0 til 3 pt +@msg `follow` must be "average", "a" or "b" `follow` skal være "average", "a" eller "b" +@msg the paths have nothing to merge there stierne har intet at flette her + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Opdel tekst + Combine Point Text Kombiner punkttekst + Remove Breaks Fjern skift + Split Text at Cursor Opdel tekst ved markøren + Set Alignment, Keep Position Angiv justering, bevar placering + Reset Text Transform Nulstil teksttransformation + Replace Missing Fonts… Erstat manglende skrifttyper… + Replace Missing Fonts Erstat manglende skrifttyper + Remove Line Breaks Fjern linjeskift + Remove Paragraph Breaks Fjern afsnitsskift + Break Text into Lines Opdel tekst i linjer + Break Text into Paragraphs Opdel tekst i afsnit + Break Text into Words Opdel tekst i ord + Break Text into Glyphs Opdel tekst i glyffer + Reset Text Transform and Scale Nulstil teksttransformation og skalering + Set Alignment, Keep Position: Angiv justering, bevar placering: + Break Text into: Opdel tekst i: + Split at Cursor Opdel ved markøren + Reset Transform Nulstil transformation + Shift-click: break into paragraphs Skift-klik: opdel i afsnit + Alt-click: reset the horizontal scale too Alt-klik: nulstil også den vandrette skalering +breakText Lines Linjer +breakText Words Ord +breakText Glyphs Glyffer +@msg Click in type with a Type tool to split it Klik i tekst med et tekstværktøj for at opdele den +@msg Too many type objects to change at once For mange tekstobjekter til at ændre på én gang +@msg Too many pieces to make at once For mange dele til at oprette på én gang +@msg Select horizontal point or area type whose text fits Vælg vandret punkt- eller områdetekst, hvis tekst kan være der +@msg Nothing to split at the cursor Intet at opdele ved markøren +@msg Select two or more one-line point type objects on one baseline to combine Vælg to eller flere punkttekstobjekter på én linje på samme grundlinje for at kombinere dem +@msg No line breaks to remove Ingen linjeskift at fjerne +@msg No paragraph breaks to remove Ingen afsnitsskift at fjerne +@msg Too many breaks to remove at once For mange skift til at fjerne på én gang +@msg Select point type Vælg punkttekst +@msg The point type is aligned that way already Punktteksten er allerede justeret sådan +@msg The point type has no transform to reset Punktteksten har ingen transformation at nulstille +@msg No fonts are missing Ingen skrifttyper mangler +@msg the replacement font is not available erstatningsskrifttypen er ikke tilgængelig +@msg missing `align` `align` mangler +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` skal være lines, paragraphs, words eller glyphs, ikke {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` skal være "line" eller "paragraph", ikke {other} +@msg `align` must be "left", "center" or "right", not {other} `align` skal være "left", "center" eller "right", ikke {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects Kurveeffekter + Reverse Direction Omvend retning + Align to Corners and Ends Juster til hjørner og ender + Count Antal + Max Count Største antal + Start Length Startlængde + Start Percent Startprocent + Start Shape Startform + End Length Slutlængde + End Percent Slutprocent + End Shape Slutform + By Count Efter antal + By Distance Efter afstand + Both Begge + Shorter Than Length Kortere end længden + Dashes to Geometry Streger til geometri + Dashes to Geometry… Streger til geometri… + Add Anchors Tilføj ankerpunkter + Add Anchors… Tilføj ankerpunkter… + Smooth Anchors Udglat ankerpunkter + Smooth Anchors… Udglat ankerpunkter… + Simplify Anchors Forenkl ankerpunkter + Simplify Anchors… Forenkl ankerpunkter… + Split at Anchors Opdel ved ankerpunkter + Split at Anchors… Opdel ved ankerpunkter… + Extend Path… Forlæng sti… + Close Path… Luk sti… + Retract Handles… Træk håndtag ind… + Dash 1 Streg 1 + Gap 1 Mellemrum 1 + Dash 2 Streg 2 + Gap 2 Mellemrum 2 + Dash 3 Streg 3 + Gap 3 Mellemrum 3 + Dash 4 Streg 4 + Gap 4 Mellemrum 4 + Dash 5 Streg 5 + Gap 5 Mellemrum 5 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 88229b4b4..34b420904 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -491,7 +491,7 @@ font-technology Variable Variabel Collect in New Layer In neuer Ebene sammeln Collect Links in a Separate Folder Verknüpfungen in separatem Ordner sammeln Color Farbe - Color (,) Farbe (,) + Color (, or <) Farbe (, oder <) Color Adjustments Farbkorrekturen Color bars Farbkontrollstreifen Color blindness – Deuteranopia-type Farbenblindheit – Deuteranopie @@ -1264,7 +1264,7 @@ file-filter Templates Vorlagen GPL palettes keep solid colours only, as RGB. GPL-Paletten enthalten nur einfarbige Farben, als RGB. GPU Performance GPU-Leistung Gradient Verlauf - Gradient (.) Verlauf (.) + Gradient (. or >) Verlauf (. oder >) Gradient across stroke Verlauf quer zur Kontur Gradient along stroke Verlauf entlang der Kontur Gradient and Mesh Resolution: Auflösung für Verläufe und Gitter: @@ -5472,3 +5472,111 @@ anchor Points: Punkte: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` geben Sie `inkLimit`, `removeWhiteOverprint` oder `overprintBlack` an @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Druckfarbkorrekturen wirken in CMYK-Dokumenten: Wählen Sie zuerst Datei › Dokumentfarbmodus › CMYK-Farbe @msg `{key}` must be 0..{max} `{key}` muss 0..{max} sein +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Ecken-Werkzeug + Slide Anchor Along Path Ankerpunkt entlang des Pfads verschieben + Set Corners Ecken festlegen + Remove Corners Ecken entfernen + Corners selected: {count} Ausgewählte Ecken: {count} + Apply to Selected Anchors Auf ausgewählte Ankerpunkte anwenden +@msg `radius` must be from 0 to 1000000 pt `radius` muss von 0 bis 1000000 pt sein +@msg `t` must be a number from -0.98 to 0.98 `t` muss eine Zahl von -0.98 bis 0.98 sein +@msg choose anchors where two sides meet at an angle: only those take a corner Ankerpunkte wählen, an denen zwei Seiten im Winkel aufeinandertreffen: nur diese können eine Ecke bekommen +@msg give `anchor`, the index of the anchor to slide `anchor` angeben, den Index des zu verschiebenden Ankerpunkts +@msg that anchor can't slide: an end of an open path has nowhere to go dieser Ankerpunkt lässt sich nicht verschieben: das Ende eines offenen Pfads kann nirgendwohin +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Gemeinsame-Kante-Werkzeug + Merge Shared Edge Gemeinsame Kante zusammenfügen + Keep Shared Points Together Gemeinsame Punkte zusammenhalten + Shared Edges Gemeinsame Kanten + Coincidence: Übereinstimmung: +@msg `a` and `b` must be two different paths `a` und `b` müssen zwei verschiedene Pfade sein +@msg `diameter` must be from 0.25 to 200 pt `diameter` muss von 0.25 bis 200 pt sein +@msg `tolerance` must be from 0 to 3 pt `tolerance` muss von 0 bis 3 pt sein +@msg `follow` must be "average", "a" or "b" `follow` muss "average", "a" oder "b" sein +@msg the paths have nothing to merge there die Pfade haben hier nichts zusammenzufügen + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Text aufteilen + Combine Point Text Punkttext zusammenfügen + Remove Breaks Umbrüche entfernen + Split Text at Cursor Text an der Einfügemarke teilen + Set Alignment, Keep Position Ausrichtung festlegen, Position beibehalten + Reset Text Transform Texttransformation zurücksetzen + Replace Missing Fonts… Fehlende Schriften ersetzen… + Replace Missing Fonts Fehlende Schriften ersetzen + Remove Line Breaks Zeilenumbrüche entfernen + Remove Paragraph Breaks Absatzumbrüche entfernen + Break Text into Lines Text in Zeilen aufteilen + Break Text into Paragraphs Text in Absätze aufteilen + Break Text into Words Text in Wörter aufteilen + Break Text into Glyphs Text in Glyphen aufteilen + Reset Text Transform and Scale Texttransformation und Skalierung zurücksetzen + Set Alignment, Keep Position: Ausrichtung festlegen, Position beibehalten: + Break Text into: Text aufteilen in: + Split at Cursor An Einfügemarke teilen + Reset Transform Transformation zurücksetzen + Shift-click: break into paragraphs Shift-Klick: in Absätze aufteilen + Alt-click: reset the horizontal scale too Alt-Klick: auch die horizontale Skalierung zurücksetzen +breakText Lines Zeilen +breakText Words Wörter +breakText Glyphs Glyphen +@msg Click in type with a Type tool to split it Klicken Sie mit einem Text-Werkzeug in Text, um ihn zu teilen +@msg Too many type objects to change at once Zu viele Textobjekte, um sie auf einmal zu ändern +@msg Too many pieces to make at once Zu viele Teile, um sie auf einmal zu erstellen +@msg Select horizontal point or area type whose text fits Wählen Sie horizontalen Punkt- oder Flächentext aus, dessen Text hineinpasst +@msg Nothing to split at the cursor An der Einfügemarke gibt es nichts zu teilen +@msg Select two or more one-line point type objects on one baseline to combine Wählen Sie zwei oder mehr einzeilige Punkttextobjekte auf einer Grundlinie aus, um sie zusammenzufügen +@msg No line breaks to remove Keine Zeilenumbrüche zu entfernen +@msg No paragraph breaks to remove Keine Absatzumbrüche zu entfernen +@msg Too many breaks to remove at once Zu viele Umbrüche, um sie auf einmal zu entfernen +@msg Select point type Wählen Sie Punkttext aus +@msg The point type is aligned that way already Der Punkttext ist bereits so ausgerichtet +@msg The point type has no transform to reset Der Punkttext hat keine Transformation zum Zurücksetzen +@msg No fonts are missing Es fehlen keine Schriften +@msg the replacement font is not available die Ersatzschrift ist nicht verfügbar +@msg missing `align` `align` fehlt +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` muss lines, paragraphs, words oder glyphs sein, nicht {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` muss "line" oder "paragraph" sein, nicht {other} +@msg `align` must be "left", "center" or "right", not {other} `align` muss "left", "center" oder "right" sein, nicht {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects Pfadeffekte + Reverse Direction Richtung umkehren + Align to Corners and Ends An Ecken und Enden ausrichten + Count Anzahl + Max Count Höchstanzahl + Start Length Startlänge + Start Percent Start in Prozent + Start Shape Startform + End Length Endlänge + End Percent Ende in Prozent + End Shape Endform + By Count Nach Anzahl + By Distance Nach Abstand + Both Beide + Shorter Than Length Kürzer als Länge + Anchors Ankerpunkte + Dashes to Geometry Striche in Geometrie + Dashes to Geometry… Striche in Geometrie… + Add Anchors Ankerpunkte hinzufügen + Add Anchors… Ankerpunkte hinzufügen… + Smooth Anchors Ankerpunkte glätten + Smooth Anchors… Ankerpunkte glätten… + Simplify Anchors Ankerpunkte vereinfachen + Simplify Anchors… Ankerpunkte vereinfachen… + Split at Anchors An Ankerpunkten teilen + Split at Anchors… An Ankerpunkten teilen… + Extend Path… Pfad verlängern… + Close Path… Pfad schließen… + Retract Handles… Griffe einziehen… + Dash 1 Strich 1 + Gap 1 Lücke 1 + Dash 2 Strich 2 + Gap 2 Lücke 2 + Dash 3 Strich 3 + Gap 3 Lücke 3 + Dash 4 Strich 4 + Gap 4 Lücke 4 + Dash 5 Strich 5 + Gap 5 Lücke 5 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c027e79d8..6637162cf 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -474,7 +474,7 @@ font-technology Variable Μεταβλητές Collect in New Layer Συγκέντρωση σε νέο επίπεδο Collect Links in a Separate Folder Συγκέντρωση συνδεδεμένων αρχείων σε ξεχωριστό φάκελο Color Χρώμα - Color (,) Χρώμα (,) + Color (, or <) Χρώμα (, ή <) Color Adjustments Ρυθμίσεις χρώματος Color bars Χρωματικές μπάρες Color blindness – Deuteranopia-type Αχρωματοψία – τύπου δευτερανοπίας @@ -1204,7 +1204,7 @@ font-technology Variable Μεταβλητές GPL palettes keep solid colours only, as RGB. Οι παλέτες GPL διατηρούν μόνο συμπαγή χρώματα σε RGB. GPU Performance Απόδοση GPU Gradient Ντεγκραντέ - Gradient (.) Ντεγκραντέ (.) + Gradient (. or >) Ντεγκραντέ (. ή >) Gradient across stroke Ντεγκραντέ εγκάρσια στο περίγραμμα Gradient along stroke Ντεγκραντέ κατά μήκος του περιγράμματος Gradient and Mesh Resolution: Ανάλυση ντεγκραντέ και πλέγματος: @@ -5839,3 +5839,111 @@ anchor Points: Σημεία: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` δώστε `inkLimit`, `removeWhiteOverprint` ή `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Οι διορθώσεις μελανιών εκτύπωσης ισχύουν σε έγγραφα CMYK: επιλέξτε πρώτα Αρχείο › Λειτουργία χρώματος εγγράφου › Χρώμα CMYK @msg `{key}` must be 0..{max} το `{key}` πρέπει να είναι 0..{max} +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Γωνίες (Corner Tool) + Slide Anchor Along Path Ολίσθηση σημείου αγκύρωσης κατά μήκος διαδρομής + Set Corners Ορισμός γωνιών + Remove Corners Αφαίρεση γωνιών + Corners selected: {count} Επιλεγμένες γωνίες: {count} + Apply to Selected Anchors Εφαρμογή στα επιλεγμένα σημεία αγκύρωσης +@msg `radius` must be from 0 to 1000000 pt το `radius` πρέπει να είναι από 0 έως 1000000 pt +@msg `t` must be a number from -0.98 to 0.98 το `t` πρέπει να είναι αριθμός από -0.98 έως 0.98 +@msg choose anchors where two sides meet at an angle: only those take a corner επιλέξτε σημεία αγκύρωσης όπου δύο πλευρές συναντώνται υπό γωνία: μόνο αυτά δέχονται γωνία +@msg give `anchor`, the index of the anchor to slide δώστε το `anchor`, τον δείκτη του σημείου αγκύρωσης προς ολίσθηση +@msg that anchor can't slide: an end of an open path has nowhere to go αυτό το σημείο αγκύρωσης δεν μπορεί να ολισθήσει: το άκρο ανοιχτής διαδρομής δεν έχει πού να πάει +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Εργαλείο συγχώνευσης κοινής ακμής + Merge Shared Edge Συγχώνευση κοινής ακμής + Keep Shared Points Together Διατήρηση κοινών σημείων μαζί + Shared Edges Κοινές ακμές + Coincidence: Σύμπτωση: +@msg `a` and `b` must be two different paths Τα `a` και `b` πρέπει να είναι δύο διαφορετικές διαδρομές +@msg `diameter` must be from 0.25 to 200 pt Το `diameter` πρέπει να είναι από 0.25 έως 200 pt +@msg `tolerance` must be from 0 to 3 pt Το `tolerance` πρέπει να είναι από 0 έως 3 pt +@msg `follow` must be "average", "a" or "b" Το `follow` πρέπει να είναι "average", "a" ή "b" +@msg the paths have nothing to merge there οι διαδρομές δεν έχουν κάτι να συγχωνευθεί εδώ + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Διαίρεση κειμένου + Combine Point Text Συνδυασμός κειμένου σημείου + Remove Breaks Αφαίρεση αλλαγών + Split Text at Cursor Διαίρεση κειμένου στον δρομέα + Set Alignment, Keep Position Ορισμός στοίχισης με διατήρηση θέσης + Reset Text Transform Επαναφορά μετασχηματισμού κειμένου + Replace Missing Fonts… Αντικατάσταση γραμματοσειρών που λείπουν… + Replace Missing Fonts Αντικατάσταση γραμματοσειρών που λείπουν + Remove Line Breaks Αφαίρεση αλλαγών γραμμής + Remove Paragraph Breaks Αφαίρεση αλλαγών παραγράφου + Break Text into Lines Διαίρεση κειμένου σε γραμμές + Break Text into Paragraphs Διαίρεση κειμένου σε παραγράφους + Break Text into Words Διαίρεση κειμένου σε λέξεις + Break Text into Glyphs Διαίρεση κειμένου σε γλύφους + Reset Text Transform and Scale Επαναφορά μετασχηματισμού και κλίμακας κειμένου + Set Alignment, Keep Position: Στοίχιση με διατήρηση θέσης: + Break Text into: Διαίρεση κειμένου σε: + Split at Cursor Διαίρεση στον δρομέα + Reset Transform Επαναφορά μετασχηματισμού + Shift-click: break into paragraphs Shift-κλικ: διαίρεση σε παραγράφους + Alt-click: reset the horizontal scale too Alt-κλικ: επαναφορά και της οριζόντιας κλίμακας +breakText Lines Γραμμές +breakText Words Λέξεις +breakText Glyphs Γλύφοι +@msg Click in type with a Type tool to split it Κάντε κλικ σε κείμενο με ένα εργαλείο κειμένου για να το διαιρέσετε +@msg Too many type objects to change at once Πάρα πολλά αντικείμενα κειμένου για αλλαγή ταυτόχρονα +@msg Too many pieces to make at once Πάρα πολλά κομμάτια για δημιουργία ταυτόχρονα +@msg Select horizontal point or area type whose text fits Επιλέξτε οριζόντιο κείμενο σημείου ή περιοχής του οποίου το κείμενο χωράει +@msg Nothing to split at the cursor Δεν υπάρχει τίποτα για διαίρεση στον δρομέα +@msg Select two or more one-line point type objects on one baseline to combine Επιλέξτε δύο ή περισσότερα μονογραμμικά αντικείμενα κειμένου σημείου στην ίδια γραμμή βάσης για να τα συνδυάσετε +@msg No line breaks to remove Δεν υπάρχουν αλλαγές γραμμής για αφαίρεση +@msg No paragraph breaks to remove Δεν υπάρχουν αλλαγές παραγράφου για αφαίρεση +@msg Too many breaks to remove at once Πάρα πολλές αλλαγές για αφαίρεση ταυτόχρονα +@msg Select point type Επιλέξτε κείμενο σημείου +@msg The point type is aligned that way already Το κείμενο σημείου είναι ήδη στοιχισμένο έτσι +@msg The point type has no transform to reset Το κείμενο σημείου δεν έχει μετασχηματισμό για επαναφορά +@msg No fonts are missing Δεν λείπει καμία γραμματοσειρά +@msg the replacement font is not available η γραμματοσειρά αντικατάστασης δεν είναι διαθέσιμη +@msg missing `align` λείπει το `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} το `by` πρέπει να είναι lines, paragraphs, words ή glyphs, όχι {other} +@msg `kind` must be "line" or "paragraph", not {other} το `kind` πρέπει να είναι "line" ή "paragraph", όχι {other} +@msg `align` must be "left", "center" or "right", not {other} το `align` πρέπει να είναι "left", "center" ή "right", όχι {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Mode Λειτουργία + Path Effects Εφέ διαδρομής + Reverse Direction Αντιστροφή κατεύθυνσης + Align to Corners and Ends Στοίχιση σε γωνίες και άκρα + Count Πλήθος + Max Count Μέγιστο πλήθος + Start Length Μήκος αρχής + Start Percent Ποσοστό αρχής + Start Shape Σχήμα αρχής + End Length Μήκος τέλους + End Percent Ποσοστό τέλους + End Shape Σχήμα τέλους + By Count Κατά πλήθος + By Distance Κατά απόσταση + Both Και τα δύο + Shorter Than Length Μικρότερες από το μήκος + Dashes to Geometry Παύλες σε γεωμετρία + Dashes to Geometry… Παύλες σε γεωμετρία… + Add Anchors Προσθήκη σημείων αγκύρωσης + Add Anchors… Προσθήκη σημείων αγκύρωσης… + Smooth Anchors Εξομάλυνση σημείων αγκύρωσης + Smooth Anchors… Εξομάλυνση σημείων αγκύρωσης… + Simplify Anchors Απλοποίηση σημείων αγκύρωσης + Simplify Anchors… Απλοποίηση σημείων αγκύρωσης… + Split at Anchors Διαχωρισμός στα σημεία αγκύρωσης + Split at Anchors… Διαχωρισμός στα σημεία αγκύρωσης… + Extend Path… Επέκταση διαδρομής… + Close Path… Κλείσιμο διαδρομής… + Retract Handles… Σύμπτυξη λαβών… + Dash 1 Παύλα 1 + Gap 1 Διάκενο 1 + Dash 2 Παύλα 2 + Gap 2 Διάκενο 2 + Dash 3 Παύλα 3 + Gap 3 Διάκενο 3 + Dash 4 Παύλα 4 + Gap 4 Διάκενο 4 + Dash 5 Παύλα 5 + Gap 5 Διάκενο 5 diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9ad5b1ad4..59742d05d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -513,7 +513,7 @@ font-technology Variable Variables Collect in New Layer Reunir en capa nueva Collect Links in a Separate Folder Reunir vínculos en una carpeta aparte Color Color - Color (,) Color (,) + Color (, or <) Color (, o <) Color Adjustments Ajustes de color Color bars Barras de color Color blindness – Deuteranopia-type Daltonismo – tipo deuteranopía @@ -1277,7 +1277,7 @@ font-technology Variable Variables GPL palettes keep solid colours only, as RGB. Las paletas GPL solo conservan colores sólidos, en RGB. GPU Performance Rendimiento de GPU Gradient Degradado - Gradient (.) Degradado (.) + Gradient (. or >) Degradado (. o >) Gradient across stroke Degradado a lo ancho del trazo Gradient along stroke Degradado a lo largo del trazo Gradient and Mesh Resolution: Resolución de degradados y mallas: @@ -5830,3 +5830,110 @@ anchor Points: Puntos: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` indique `inkLimit`, `removeWhiteOverprint` u `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Las correcciones de tinta para impresión funcionan en documentos CMYK: elija primero Archivo › Modo de color del documento › Color CMYK @msg `{key}` must be 0..{max} `{key}` debe ser 0..{max} +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Herramienta Esquinas + Slide Anchor Along Path Deslizar punto de ancla por el trazado + Set Corners Definir esquinas + Remove Corners Quitar esquinas + Corners selected: {count} Esquinas seleccionadas: {count} + Apply to Selected Anchors Aplicar a los puntos de ancla seleccionados +@msg `radius` must be from 0 to 1000000 pt `radius` debe ser de 0 a 1000000 pt +@msg `t` must be a number from -0.98 to 0.98 `t` debe ser un número de -0.98 a 0.98 +@msg choose anchors where two sides meet at an angle: only those take a corner elija puntos de ancla donde dos lados se encuentren en ángulo: solo esos admiten una esquina +@msg give `anchor`, the index of the anchor to slide indique `anchor`, el índice del punto de ancla que deslizar +@msg that anchor can't slide: an end of an open path has nowhere to go ese punto de ancla no se puede deslizar: el extremo de un trazado abierto no tiene adónde ir +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Herramienta Combinar borde compartido + Merge Shared Edge Combinar borde compartido + Keep Shared Points Together Mantener juntos los puntos compartidos + Shared Edges Bordes compartidos + Coincidence: Coincidencia: +@msg `a` and `b` must be two different paths `a` y `b` deben ser dos trazados distintos +@msg `diameter` must be from 0.25 to 200 pt `diameter` debe ser de 0.25 a 200 pt +@msg `tolerance` must be from 0 to 3 pt `tolerance` debe ser de 0 a 3 pt +@msg `follow` must be "average", "a" or "b" `follow` debe ser "average", "a" o "b" +@msg the paths have nothing to merge there los trazados no tienen nada que combinar aquí + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Dividir texto + Combine Point Text Combinar texto de punto + Remove Breaks Quitar saltos + Split Text at Cursor Dividir texto en el cursor + Set Alignment, Keep Position Definir alineación manteniendo la posición + Reset Text Transform Restablecer transformación del texto + Replace Missing Fonts… Reemplazar fuentes que faltan… + Replace Missing Fonts Reemplazar fuentes que faltan + Remove Line Breaks Quitar saltos de línea + Remove Paragraph Breaks Quitar saltos de párrafo + Break Text into Lines Dividir texto en líneas + Break Text into Paragraphs Dividir texto en párrafos + Break Text into Words Dividir texto en palabras + Break Text into Glyphs Dividir texto en glifos + Reset Text Transform and Scale Restablecer transformación y escala del texto + Set Alignment, Keep Position: Definir alineación manteniendo la posición: + Break Text into: Dividir texto en: + Split at Cursor Dividir en el cursor + Reset Transform Restablecer transformación + Shift-click: break into paragraphs Shift+clic: dividir en párrafos + Alt-click: reset the horizontal scale too Alt+clic: restablecer también la escala horizontal +breakText Lines Líneas +breakText Words Palabras +breakText Glyphs Glifos +@msg Click in type with a Type tool to split it Haga clic en un texto con una herramienta Texto para dividirlo +@msg Too many type objects to change at once Demasiados objetos de texto para cambiarlos a la vez +@msg Too many pieces to make at once Demasiadas piezas para crearlas a la vez +@msg Select horizontal point or area type whose text fits Seleccione texto de punto o de área horizontal cuyo texto quepa +@msg Nothing to split at the cursor No hay nada que dividir en el cursor +@msg Select two or more one-line point type objects on one baseline to combine Seleccione dos o más objetos de texto de punto de una línea sobre la misma línea base para combinarlos +@msg No line breaks to remove No hay saltos de línea que quitar +@msg No paragraph breaks to remove No hay saltos de párrafo que quitar +@msg Too many breaks to remove at once Demasiados saltos para quitarlos a la vez +@msg Select point type Seleccione texto de punto +@msg The point type is aligned that way already El texto de punto ya está alineado así +@msg The point type has no transform to reset El texto de punto no tiene ninguna transformación que restablecer +@msg No fonts are missing No falta ninguna fuente +@msg the replacement font is not available la fuente de reemplazo no está disponible +@msg missing `align` falta `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` debe ser lines, paragraphs, words o glyphs, no {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` debe ser "line" o "paragraph", no {other} +@msg `align` must be "left", "center" or "right", not {other} `align` debe ser "left", "center" o "right", no {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects Efectos de trazado + Reverse Direction Invertir dirección + Align to Corners and Ends Alinear con esquinas y extremos + Count Cantidad + Max Count Cantidad máxima + Start Length Longitud inicial + Start Percent Porcentaje inicial + Start Shape Forma inicial + End Length Longitud final + End Percent Porcentaje final + End Shape Forma final + By Count Por cantidad + By Distance Por distancia + Both Ambos + Shorter Than Length Más cortos que la longitud + Dashes to Geometry Guiones a geometría + Dashes to Geometry… Guiones a geometría… + Add Anchors Agregar puntos de ancla + Add Anchors… Agregar puntos de ancla… + Smooth Anchors Suavizar puntos de ancla + Smooth Anchors… Suavizar puntos de ancla… + Simplify Anchors Simplificar puntos de ancla + Simplify Anchors… Simplificar puntos de ancla… + Split at Anchors Dividir en puntos de ancla + Split at Anchors… Dividir en puntos de ancla… + Extend Path… Prolongar trazado… + Close Path… Cerrar trazado… + Retract Handles… Retraer manejadores… + Dash 1 Guion 1 + Gap 1 Hueco 1 + Dash 2 Guion 2 + Gap 2 Hueco 2 + Dash 3 Guion 3 + Gap 3 Hueco 3 + Dash 4 Guion 4 + Gap 4 Hueco 4 + Dash 5 Guion 5 + Gap 5 Hueco 5 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e5ab17856..ab4f30b41 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1187,12 +1187,12 @@ lighting Light Valo to set spacing and orientation asettaaksesi välit ja suunnan Apply Käytä Change Screen Mode (F) Vaihda näyttötilaa (F) - Color (,) Väri (,) + Color (, or <) Väri (, tai <) Draw Behind Piirrä taakse Draw Inside Piirrä sisään Draw Normal Piirrä normaalisti Expand: Laajenna: - Gradient (.) Liukuväri (.) + Gradient (. or >) Liukuväri (. tai >) H: K: Modify Muokkaa Navigate Siirry @@ -5918,3 +5918,110 @@ anchor Points: Pisteet: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` anna `inkLimit`, `removeWhiteOverprint` tai `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Painovärien korjaukset toimivat CMYK-asiakirjoissa: valitse ensin Tiedosto › Asiakirjan väritila › CMYK-väri @msg `{key}` must be 0..{max} `{key}` on oltava 0..{max} +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Kulmatyökalu + Slide Anchor Along Path Liu’uta ankkuripistettä polkua pitkin + Set Corners Aseta kulmat + Remove Corners Poista kulmat + Corners selected: {count} Valittuja kulmia: {count} + Apply to Selected Anchors Käytä valittuihin ankkuripisteisiin +@msg `radius` must be from 0 to 1000000 pt `radius` on oltava 0–1000000 pt +@msg `t` must be a number from -0.98 to 0.98 `t` on oltava luku väliltä -0.98–0.98 +@msg choose anchors where two sides meet at an angle: only those take a corner valitse ankkuripisteet, joissa kaksi sivua kohtaa kulmassa: vain niihin voi tehdä kulman +@msg give `anchor`, the index of the anchor to slide anna `anchor`, liu’utettavan ankkuripisteen indeksi +@msg that anchor can't slide: an end of an open path has nowhere to go tätä ankkuripistettä ei voi liu’uttaa: avoimen polun päällä ei ole minne mennä +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Yhteisen reunan yhdistämistyökalu + Merge Shared Edge Yhdistä yhteinen reuna + Keep Shared Points Together Pidä yhteiset pisteet yhdessä + Shared Edges Yhteiset reunat + Coincidence: Yhtenevyys: +@msg `a` and `b` must be two different paths `a` ja `b` on oltava kaksi eri polkua +@msg `diameter` must be from 0.25 to 200 pt `diameter` on oltava 0.25–200 pt +@msg `tolerance` must be from 0 to 3 pt `tolerance` on oltava 0–3 pt +@msg `follow` must be "average", "a" or "b" `follow` on oltava "average", "a" tai "b" +@msg the paths have nothing to merge there poluilla ei ole tässä mitään yhdistettävää + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Jaa teksti + Combine Point Text Yhdistä pisteteksti + Remove Breaks Poista vaihdot + Split Text at Cursor Jaa teksti kohdistimen kohdalta + Set Alignment, Keep Position Aseta tasaus, säilytä sijainti + Reset Text Transform Palauta tekstin muunnos + Replace Missing Fonts… Korvaa puuttuvat fontit… + Replace Missing Fonts Korvaa puuttuvat fontit + Remove Line Breaks Poista rivinvaihdot + Remove Paragraph Breaks Poista kappaleenvaihdot + Break Text into Lines Jaa teksti riveiksi + Break Text into Paragraphs Jaa teksti kappaleiksi + Break Text into Words Jaa teksti sanoiksi + Break Text into Glyphs Jaa teksti merkeiksi + Reset Text Transform and Scale Palauta tekstin muunnos ja skaalaus + Set Alignment, Keep Position: Aseta tasaus, säilytä sijainti: + Break Text into: Jaa teksti: + Split at Cursor Jaa kohdistimen kohdalta + Reset Transform Palauta muunnos + Shift-click: break into paragraphs Vaihto-napsautus: jaa kappaleiksi + Alt-click: reset the horizontal scale too Alt-napsautus: palauta myös vaakaskaalaus +breakText Lines Rivit +breakText Words Sanat +breakText Glyphs Merkit +@msg Click in type with a Type tool to split it Napsauta tekstiä tekstityökalulla jakaaksesi sen +@msg Too many type objects to change at once Liian monta tekstiobjektia muutettavaksi kerralla +@msg Too many pieces to make at once Liian monta osaa luotavaksi kerralla +@msg Select horizontal point or area type whose text fits Valitse vaakasuora piste- tai alueteksti, jonka teksti mahtuu +@msg Nothing to split at the cursor Kohdistimen kohdalla ei ole jaettavaa +@msg Select two or more one-line point type objects on one baseline to combine Valitse kaksi tai useampia yksirivisiä pistetekstiobjekteja samalla perusviivalla yhdistääksesi ne +@msg No line breaks to remove Ei poistettavia rivinvaihtoja +@msg No paragraph breaks to remove Ei poistettavia kappaleenvaihtoja +@msg Too many breaks to remove at once Liian monta vaihtoa poistettavaksi kerralla +@msg Select point type Valitse pisteteksti +@msg The point type is aligned that way already Pisteteksti on jo tasattu näin +@msg The point type has no transform to reset Pistetekstillä ei ole palautettavaa muunnosta +@msg No fonts are missing Fontteja ei puutu +@msg the replacement font is not available korvaava fontti ei ole käytettävissä +@msg missing `align` `align` puuttuu +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` on oltava lines, paragraphs, words tai glyphs, ei {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` on oltava "line" tai "paragraph", ei {other} +@msg `align` must be "left", "center" or "right", not {other} `align` on oltava "left", "center" tai "right", ei {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects Polkutehosteet + Reverse Direction Käännä suunta + Align to Corners and Ends Tasaa kulmiin ja päihin + Count Määrä + Max Count Enimmäismäärä + Start Length Alun pituus + Start Percent Alun prosentti + Start Shape Alun muoto + End Length Lopun pituus + End Percent Lopun prosentti + End Shape Lopun muoto + By Count Määrän mukaan + By Distance Etäisyyden mukaan + Both Molemmat + Shorter Than Length Pituutta lyhyemmät + Dashes to Geometry Katkoviivat geometriaksi + Dashes to Geometry… Katkoviivat geometriaksi… + Add Anchors Lisää ankkuripisteitä + Add Anchors… Lisää ankkuripisteitä… + Smooth Anchors Pyöristä ankkuripisteet + Smooth Anchors… Pyöristä ankkuripisteet… + Simplify Anchors Yksinkertaista ankkuripisteet + Simplify Anchors… Yksinkertaista ankkuripisteet… + Split at Anchors Jaa ankkuripisteistä + Split at Anchors… Jaa ankkuripisteistä… + Extend Path… Jatka polkua… + Close Path… Sulje polku… + Retract Handles… Vedä kahvat sisään… + Dash 1 Viiva 1 + Gap 1 Väli 1 + Dash 2 Viiva 2 + Gap 2 Väli 2 + Dash 3 Viiva 3 + Gap 3 Väli 3 + Dash 4 Viiva 4 + Gap 4 Väli 4 + Dash 5 Viiva 5 + Gap 5 Väli 5 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f222f43dd..1ea4d164c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -514,7 +514,7 @@ font-technology Variable Variables Collect in New Layer Rassembler dans un nouveau calque Collect Links in a Separate Folder Rassembler les liens dans un dossier distinct Color Couleur - Color (,) Couleur (,) + Color (, or <) Couleur (, ou <) Color Adjustments Réglages de couleur Color bars Barres de couleurs Color blindness – Deuteranopia-type Daltonisme – type deutéranopie @@ -1276,7 +1276,7 @@ font-technology Variable Variables GPL palettes keep solid colours only, as RGB. Les palettes GPL ne conservent que les couleurs unies, en RVB. GPU Performance Performances GPU Gradient Dégradé - Gradient (.) Dégradé (.) + Gradient (. or >) Dégradé (. ou >) Gradient across stroke Dégradé sur la largeur du contour Gradient along stroke Dégradé le long du contour Gradient and Mesh Resolution: Résolution des dégradés et des filets : @@ -5886,3 +5886,110 @@ anchor Points: Points : @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` indiquez `inkLimit`, `removeWhiteOverprint` ou `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Les corrections d’encres pour l’impression s’appliquent aux documents CMJN : choisissez d’abord Fichier › Mode colorimétrique du document › Couleur CMJN @msg `{key}` must be 0..{max} `{key}` doit être compris entre 0 et {max} +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Outil Angles + Slide Anchor Along Path Faire glisser le point d’ancrage le long du tracé + Set Corners Définir les angles + Remove Corners Supprimer les angles + Corners selected: {count} Angles sélectionnés : {count} + Apply to Selected Anchors Appliquer aux points d’ancrage sélectionnés +@msg `radius` must be from 0 to 1000000 pt `radius` doit être compris entre 0 et 1000000 pt +@msg `t` must be a number from -0.98 to 0.98 `t` doit être un nombre compris entre -0.98 et 0.98 +@msg choose anchors where two sides meet at an angle: only those take a corner choisissez des points d’ancrage où deux côtés se rencontrent en formant un angle : seuls ceux-ci acceptent un angle +@msg give `anchor`, the index of the anchor to slide indiquez `anchor`, l’indice du point d’ancrage à faire glisser +@msg that anchor can't slide: an end of an open path has nowhere to go ce point d’ancrage ne peut pas glisser : l’extrémité d’un tracé ouvert n’a nulle part où aller +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Outil Fusion d’arête partagée + Merge Shared Edge Fusionner l’arête partagée + Keep Shared Points Together Garder ensemble les points partagés + Shared Edges Arêtes partagées + Coincidence: Coïncidence : +@msg `a` and `b` must be two different paths `a` et `b` doivent être deux tracés différents +@msg `diameter` must be from 0.25 to 200 pt `diameter` doit être de 0.25 à 200 pt +@msg `tolerance` must be from 0 to 3 pt `tolerance` doit être de 0 à 3 pt +@msg `follow` must be "average", "a" or "b" `follow` doit valoir "average", "a" ou "b" +@msg the paths have nothing to merge there les tracés n’ont rien à fusionner ici + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Diviser le texte + Combine Point Text Combiner le texte de point + Remove Breaks Supprimer les sauts + Split Text at Cursor Diviser le texte au curseur + Set Alignment, Keep Position Définir l’alignement en conservant la position + Reset Text Transform Réinitialiser la transformation du texte + Replace Missing Fonts… Remplacer les polices manquantes… + Replace Missing Fonts Remplacer les polices manquantes + Remove Line Breaks Supprimer les sauts de ligne + Remove Paragraph Breaks Supprimer les sauts de paragraphe + Break Text into Lines Diviser le texte en lignes + Break Text into Paragraphs Diviser le texte en paragraphes + Break Text into Words Diviser le texte en mots + Break Text into Glyphs Diviser le texte en glyphes + Reset Text Transform and Scale Réinitialiser la transformation et l’échelle du texte + Set Alignment, Keep Position: Définir l’alignement en conservant la position : + Break Text into: Diviser le texte en : + Split at Cursor Diviser au curseur + Reset Transform Réinitialiser la transformation + Shift-click: break into paragraphs Maj+clic : diviser en paragraphes + Alt-click: reset the horizontal scale too Alt+clic : réinitialiser aussi l’échelle horizontale +breakText Lines Lignes +breakText Words Mots +breakText Glyphs Glyphes +@msg Click in type with a Type tool to split it Cliquez dans du texte avec un outil Texte pour le diviser +@msg Too many type objects to change at once Trop d’objets texte à modifier en une fois +@msg Too many pieces to make at once Trop de morceaux à créer en une fois +@msg Select horizontal point or area type whose text fits Sélectionnez du texte de point ou captif horizontal dont le texte tient dans son cadre +@msg Nothing to split at the cursor Rien à diviser au curseur +@msg Select two or more one-line point type objects on one baseline to combine Sélectionnez au moins deux objets texte de point d’une ligne sur une même ligne de base pour les combiner +@msg No line breaks to remove Aucun saut de ligne à supprimer +@msg No paragraph breaks to remove Aucun saut de paragraphe à supprimer +@msg Too many breaks to remove at once Trop de sauts à supprimer en une fois +@msg Select point type Sélectionnez du texte de point +@msg The point type is aligned that way already Le texte de point est déjà aligné ainsi +@msg The point type has no transform to reset Le texte de point n’a aucune transformation à réinitialiser +@msg No fonts are missing Aucune police ne manque +@msg the replacement font is not available la police de remplacement n’est pas disponible +@msg missing `align` `align` manquant +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` doit valoir lines, paragraphs, words ou glyphs, et non {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` doit valoir "line" ou "paragraph", et non {other} +@msg `align` must be "left", "center" or "right", not {other} `align` doit valoir "left", "center" ou "right", et non {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects Effets de tracé + Reverse Direction Inverser le sens + Align to Corners and Ends Aligner sur les angles et extrémités + Count Nombre + Max Count Nombre maximal + Start Length Longueur au début + Start Percent Pourcentage au début + Start Shape Forme au début + End Length Longueur à la fin + End Percent Pourcentage à la fin + End Shape Forme à la fin + By Count Par nombre + By Distance Par distance + Both Les deux + Shorter Than Length Plus courtes que la longueur + Dashes to Geometry Tirets en géométrie + Dashes to Geometry… Tirets en géométrie… + Add Anchors Ajouter des points d’ancrage + Add Anchors… Ajouter des points d’ancrage… + Smooth Anchors Lisser les points d’ancrage + Smooth Anchors… Lisser les points d’ancrage… + Simplify Anchors Simplifier les points d’ancrage + Simplify Anchors… Simplifier les points d’ancrage… + Split at Anchors Scinder aux points d’ancrage + Split at Anchors… Scinder aux points d’ancrage… + Extend Path… Prolonger le tracé… + Close Path… Fermer le tracé… + Retract Handles… Rétracter les poignées… + Dash 1 Tiret 1 + Gap 1 Écart 1 + Dash 2 Tiret 2 + Gap 2 Écart 2 + Dash 3 Tiret 3 + Gap 3 Écart 3 + Dash 4 Tiret 4 + Gap 4 Écart 4 + Dash 5 Tiret 5 + Gap 5 Écart 5 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e20d3ae35..a0e40d2a2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -683,7 +683,7 @@ font-technology Variable Változó Collect in New Layer Összegyűjtés új rétegbe Collect Links in a Separate Folder Csatolások gyűjtése külön mappába Color Szín - Color (,) Szín (,) + Color (, or <) Szín (, vagy <) Color Adjustments Színkorrekciók Color bars Színsávok Color blindness – Deuteranopia-type Színvakság – deuteranópia @@ -1595,7 +1595,7 @@ file-filter Templates Sablonok GPU Performance GPU-teljesítmény Grab bag Vegyes Gradient Színátmenet - Gradient (.) Színátmenet (.) + Gradient (. or >) Színátmenet (. vagy >) Gradient across stroke Színátmenet a körvonalon keresztben Gradient along stroke Színátmenet a körvonal mentén Gradient and Mesh Resolution: Színátmenet és háló felbontása: @@ -5874,3 +5874,110 @@ anchor Points: Pontok: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` adja meg: `inkLimit`, `removeWhiteOverprint` vagy `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first A nyomdai festékjavítások CMYK-dokumentumokon működnek: előbb válassza a Fájl › Dokumentum színmódja › CMYK-szín parancsot @msg `{key}` must be 0..{max} `{key}` értéke 0..{max} lehet +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Sarok eszköz + Slide Anchor Along Path Horgonypont csúsztatása az útvonal mentén + Set Corners Sarkok beállítása + Remove Corners Sarkok eltávolítása + Corners selected: {count} Kijelölt sarkok: {count} + Apply to Selected Anchors Alkalmazás a kijelölt horgonypontokra +@msg `radius` must be from 0 to 1000000 pt a `radius` értéke 0 és 1000000 pt között lehet +@msg `t` must be a number from -0.98 to 0.98 a `t` értéke -0.98 és 0.98 közötti szám lehet +@msg choose anchors where two sides meet at an angle: only those take a corner válasszon olyan horgonypontokat, ahol két oldal szögben találkozik: csak ezek kaphatnak sarkot +@msg give `anchor`, the index of the anchor to slide adja meg az `anchor` értékét, a csúsztatandó horgonypont indexét +@msg that anchor can't slide: an end of an open path has nowhere to go ez a horgonypont nem csúsztatható: a nyitott útvonal végének nincs hová mennie +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Közös él egyesítése eszköz + Merge Shared Edge Közös él egyesítése + Keep Shared Points Together Közös pontok együtt tartása + Shared Edges Közös élek + Coincidence: Egybeesés: +@msg `a` and `b` must be two different paths `a` és `b` két különböző útvonal kell legyen +@msg `diameter` must be from 0.25 to 200 pt `diameter`: 0.25 és 200 pt között kell +@msg `tolerance` must be from 0 to 3 pt `tolerance`: 0 és 3 pt között kell +@msg `follow` must be "average", "a" or "b" `follow`: "average", "a" vagy "b" kell +@msg the paths have nothing to merge there az útvonalaknak itt nincs mit egyesíteni + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Szöveg felosztása + Combine Point Text Pontszöveg egyesítése + Remove Breaks Törések eltávolítása + Split Text at Cursor Szöveg felosztása a kurzornál + Set Alignment, Keep Position Igazítás beállítása, helyzet megtartása + Reset Text Transform Szövegátalakítás visszaállítása + Replace Missing Fonts… Hiányzó betűtípusok cseréje… + Replace Missing Fonts Hiányzó betűtípusok cseréje + Remove Line Breaks Sortörések eltávolítása + Remove Paragraph Breaks Bekezdéstörések eltávolítása + Break Text into Lines Szöveg felosztása sorokra + Break Text into Paragraphs Szöveg felosztása bekezdésekre + Break Text into Words Szöveg felosztása szavakra + Break Text into Glyphs Szöveg felosztása glifákra + Reset Text Transform and Scale Szövegátalakítás és méretezés visszaállítása + Set Alignment, Keep Position: Igazítás beállítása, helyzet megtartása: + Break Text into: Szöveg felosztása: + Split at Cursor Felosztás a kurzornál + Reset Transform Átalakítás visszaállítása + Shift-click: break into paragraphs Shift+kattintás: felosztás bekezdésekre + Alt-click: reset the horizontal scale too Alt+kattintás: a vízszintes méretezés visszaállítása is +breakText Lines Sorok +breakText Words Szavak +breakText Glyphs Glifák +@msg Click in type with a Type tool to split it Kattintson a szövegbe egy szöveg eszközzel a felosztáshoz +@msg Too many type objects to change at once Túl sok szövegobjektum az egyszerre történő módosításhoz +@msg Too many pieces to make at once Túl sok darab az egyszerre történő létrehozáshoz +@msg Select horizontal point or area type whose text fits Jelöljön ki vízszintes pont- vagy területszöveget, amelynek szövege elfér +@msg Nothing to split at the cursor A kurzornál nincs mit felosztani +@msg Select two or more one-line point type objects on one baseline to combine Az egyesítéshez jelöljön ki legalább két egysoros pontszöveg-objektumot egy alapvonalon +@msg No line breaks to remove Nincs eltávolítandó sortörés +@msg No paragraph breaks to remove Nincs eltávolítandó bekezdéstörés +@msg Too many breaks to remove at once Túl sok törés az egyszerre történő eltávolításhoz +@msg Select point type Jelöljön ki pontszöveget +@msg The point type is aligned that way already A pontszöveg már így van igazítva +@msg The point type has no transform to reset A pontszövegnek nincs visszaállítandó átalakítása +@msg No fonts are missing Nem hiányzik betűtípus +@msg the replacement font is not available a helyettesítő betűtípus nem érhető el +@msg missing `align` hiányzik az `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} a `by` értéke lines, paragraphs, words vagy glyphs lehet, nem {other} +@msg `kind` must be "line" or "paragraph", not {other} a `kind` értéke "line" vagy "paragraph" lehet, nem {other} +@msg `align` must be "left", "center" or "right", not {other} az `align` értéke "left", "center" vagy "right" lehet, nem {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects Útvonaleffektusok + Reverse Direction Irány megfordítása + Align to Corners and Ends Igazítás a sarkokhoz és végekhez + Count Darabszám + Max Count Legnagyobb darabszám + Start Length Kezdő hossz + Start Percent Kezdő százalék + Start Shape Kezdő alak + End Length Záró hossz + End Percent Záró százalék + End Shape Záró alak + By Count Darabszám szerint + By Distance Távolság szerint + Both Mindkettő + Shorter Than Length A hossznál rövidebbek + Dashes to Geometry Vonalak geometriává + Dashes to Geometry… Vonalak geometriává… + Add Anchors Horgonypontok hozzáadása + Add Anchors… Horgonypontok hozzáadása… + Smooth Anchors Horgonypontok simítása + Smooth Anchors… Horgonypontok simítása… + Simplify Anchors Horgonypontok egyszerűsítése + Simplify Anchors… Horgonypontok egyszerűsítése… + Split at Anchors Felosztás a horgonypontoknál + Split at Anchors… Felosztás a horgonypontoknál… + Extend Path… Útvonal meghosszabbítása… + Close Path… Útvonal lezárása… + Retract Handles… Fogantyúk behúzása… + Dash 1 Vonal 1 + Gap 1 Hézag 1 + Dash 2 Vonal 2 + Gap 2 Hézag 2 + Dash 3 Vonal 3 + Gap 3 Hézag 3 + Dash 4 Vonal 4 + Gap 4 Hézag 4 + Dash 5 Vonal 5 + Gap 5 Hézag 5 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 2c683cc19..c892e1cdb 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1127,12 +1127,12 @@ lighting Light Cahaya to set spacing and orientation untuk mengatur jarak dan orientasi Apply Terapkan Change Screen Mode (F) Ganti mode layar (F) - Color (,) Warna (,) + Color (, or <) Warna (, atau <) Draw Behind Gambar di belakang Draw Inside Gambar di dalam Draw Normal Gambar normal Expand: Perluas: - Gradient (.) Gradien (.) + Gradient (. or >) Gradien (. atau >) H: H: Modify Ubah Navigate Navigasi @@ -5791,3 +5791,110 @@ anchor Points: Titik: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` berikan `inkLimit`, `removeWhiteOverprint`, atau `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Perbaikan Tinta Cetak bekerja pada dokumen CMYK: pilih dulu Berkas › Mode warna dokumen › Warna CMYK @msg `{key}` must be 0..{max} `{key}` harus 0..{max} +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Alat sudut + Slide Anchor Along Path Geser titik jangkar sepanjang jalur + Set Corners Atur sudut + Remove Corners Hapus sudut + Corners selected: {count} Sudut terpilih: {count} + Apply to Selected Anchors Terapkan ke titik jangkar terpilih +@msg `radius` must be from 0 to 1000000 pt `radius` harus dari 0 sampai 1000000 pt +@msg `t` must be a number from -0.98 to 0.98 `t` harus berupa angka dari -0.98 sampai 0.98 +@msg choose anchors where two sides meet at an angle: only those take a corner pilih titik jangkar tempat dua sisi bertemu membentuk sudut: hanya titik itu yang dapat diberi sudut +@msg give `anchor`, the index of the anchor to slide berikan `anchor`, indeks titik jangkar yang akan digeser +@msg that anchor can't slide: an end of an open path has nowhere to go titik jangkar itu tidak dapat digeser: ujung jalur terbuka tidak punya arah tujuan +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Alat gabung tepi bersama + Merge Shared Edge Gabungkan tepi bersama + Keep Shared Points Together Jaga titik bersama tetap menyatu + Shared Edges Tepi bersama + Coincidence: Kesamaan posisi: +@msg `a` and `b` must be two different paths `a` dan `b` harus dua jalur yang berbeda +@msg `diameter` must be from 0.25 to 200 pt `diameter` harus dari 0.25 hingga 200 pt +@msg `tolerance` must be from 0 to 3 pt `tolerance` harus dari 0 hingga 3 pt +@msg `follow` must be "average", "a" or "b" `follow` harus "average", "a" atau "b" +@msg the paths have nothing to merge there tidak ada yang dapat digabungkan dari jalur di sini + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Pisahkan teks + Combine Point Text Gabungkan teks titik + Remove Breaks Hapus pemisah + Split Text at Cursor Pisahkan teks di kursor + Set Alignment, Keep Position Atur perataan, pertahankan posisi + Reset Text Transform Atur ulang transformasi teks + Replace Missing Fonts… Ganti font yang hilang… + Replace Missing Fonts Ganti font yang hilang + Remove Line Breaks Hapus putus baris + Remove Paragraph Breaks Hapus putus paragraf + Break Text into Lines Pisahkan teks menjadi baris + Break Text into Paragraphs Pisahkan teks menjadi paragraf + Break Text into Words Pisahkan teks menjadi kata + Break Text into Glyphs Pisahkan teks menjadi glif + Reset Text Transform and Scale Atur ulang transformasi dan skala teks + Set Alignment, Keep Position: Atur perataan, pertahankan posisi: + Break Text into: Pisahkan teks menjadi: + Split at Cursor Pisahkan di kursor + Reset Transform Atur ulang transformasi + Shift-click: break into paragraphs Shift+klik: pisahkan menjadi paragraf + Alt-click: reset the horizontal scale too Alt+klik: atur ulang juga skala horizontal +breakText Lines Baris +breakText Words Kata +breakText Glyphs Glif +@msg Click in type with a Type tool to split it Klik di dalam teks dengan alat teks untuk memisahkannya +@msg Too many type objects to change at once Terlalu banyak objek teks untuk diubah sekaligus +@msg Too many pieces to make at once Terlalu banyak bagian untuk dibuat sekaligus +@msg Select horizontal point or area type whose text fits Pilih teks titik atau teks area horizontal yang teksnya muat +@msg Nothing to split at the cursor Tidak ada yang dapat dipisahkan di kursor +@msg Select two or more one-line point type objects on one baseline to combine Pilih dua atau lebih objek teks titik satu baris pada satu garis dasar untuk menggabungkannya +@msg No line breaks to remove Tidak ada putus baris untuk dihapus +@msg No paragraph breaks to remove Tidak ada putus paragraf untuk dihapus +@msg Too many breaks to remove at once Terlalu banyak pemisah untuk dihapus sekaligus +@msg Select point type Pilih teks titik +@msg The point type is aligned that way already Teks titik sudah diratakan seperti itu +@msg The point type has no transform to reset Teks titik tidak memiliki transformasi untuk diatur ulang +@msg No fonts are missing Tidak ada font yang hilang +@msg the replacement font is not available font pengganti tidak tersedia +@msg missing `align` `align` tidak ada +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` harus lines, paragraphs, words, atau glyphs, bukan {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` harus "line" atau "paragraph", bukan {other} +@msg `align` must be "left", "center" or "right", not {other} `align` harus "left", "center", atau "right", bukan {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects Efek jalur + Reverse Direction Balikkan arah + Align to Corners and Ends Sejajarkan ke sudut dan ujung + Count Jumlah + Max Count Jumlah maksimum + Start Length Panjang awal + Start Percent Persen awal + Start Shape Bentuk awal + End Length Panjang akhir + End Percent Persen akhir + End Shape Bentuk akhir + By Count Menurut jumlah + By Distance Menurut jarak + Both Keduanya + Shorter Than Length Lebih pendek dari panjang + Dashes to Geometry Garis putus ke geometri + Dashes to Geometry… Garis putus ke geometri… + Add Anchors Tambahkan titik jangkar + Add Anchors… Tambahkan titik jangkar… + Smooth Anchors Haluskan titik jangkar + Smooth Anchors… Haluskan titik jangkar… + Simplify Anchors Sederhanakan titik jangkar + Simplify Anchors… Sederhanakan titik jangkar… + Split at Anchors Pisahkan di titik jangkar + Split at Anchors… Pisahkan di titik jangkar… + Extend Path… Perpanjang jalur… + Close Path… Tutup jalur… + Retract Handles… Tarik masuk pegangan… + Dash 1 Garis 1 + Gap 1 Jarak 1 + Dash 2 Garis 2 + Gap 2 Jarak 2 + Dash 3 Garis 3 + Gap 3 Jarak 3 + Dash 4 Garis 4 + Gap 4 Jarak 4 + Dash 5 Garis 5 + Gap 5 Jarak 5 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7b24cd4c5..ad810f7da 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -513,7 +513,7 @@ font-technology Variable Variabili Collect in New Layer Raccogli in un nuovo livello Collect Links in a Separate Folder Raccogli i collegamenti in una cartella separata Color Colore - Color (,) Colore (,) + Color (, or <) Colore (, o <) Color Adjustments Regolazioni colore Color bars Barre colore Color blindness – Deuteranopia-type Daltonismo – tipo deuteranopia @@ -1287,7 +1287,7 @@ file-filter Templates Modelli GPL palettes keep solid colours only, as RGB. Le palette GPL conservano solo i colori pieni, in RGB. GPU Performance Prestazioni GPU Gradient Sfumatura - Gradient (.) Sfumatura (.) + Gradient (. or >) Sfumatura (. o >) Gradient across stroke Sfumatura trasversale alla traccia Gradient along stroke Sfumatura lungo la traccia Gradient and Mesh Resolution: Risoluzione sfumature e trame: @@ -5506,3 +5506,111 @@ anchor Points: Punti: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` indica `inkLimit`, `removeWhiteOverprint` o `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Le correzioni inchiostri di stampa funzionano nei documenti CMYK: scegli prima File › Metodo colore documento › Colore CMYK @msg `{key}` must be 0..{max} `{key}` deve essere 0..{max} +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Strumento Angoli + Slide Anchor Along Path Fai scorrere il punto di ancoraggio lungo il tracciato + Set Corners Imposta angoli + Remove Corners Rimuovi angoli + Corners selected: {count} Angoli selezionati: {count} + Apply to Selected Anchors Applica ai punti di ancoraggio selezionati +@msg `radius` must be from 0 to 1000000 pt `radius` deve essere da 0 a 1000000 pt +@msg `t` must be a number from -0.98 to 0.98 `t` deve essere un numero da -0.98 a 0.98 +@msg choose anchors where two sides meet at an angle: only those take a corner scegli punti di ancoraggio in cui due lati si incontrano ad angolo: solo quelli accettano un angolo +@msg give `anchor`, the index of the anchor to slide indica `anchor`, l’indice del punto di ancoraggio da far scorrere +@msg that anchor can't slide: an end of an open path has nowhere to go quel punto di ancoraggio non può scorrere: l’estremità di un tracciato aperto non ha dove andare +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Strumento Fondi bordo condiviso + Merge Shared Edge Fondi bordo condiviso + Keep Shared Points Together Mantieni uniti i punti condivisi + Shared Edges Bordi condivisi + Coincidence: Coincidenza: +@msg `a` and `b` must be two different paths `a` e `b` devono essere due tracciati diversi +@msg `diameter` must be from 0.25 to 200 pt `diameter` deve essere da 0.25 a 200 pt +@msg `tolerance` must be from 0 to 3 pt `tolerance` deve essere da 0 a 3 pt +@msg `follow` must be "average", "a" or "b" `follow` deve essere "average", "a" o "b" +@msg the paths have nothing to merge there i tracciati non hanno nulla da fondere qui + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Dividi testo + Combine Point Text Combina testo con punto + Remove Breaks Rimuovi interruzioni + Split Text at Cursor Dividi testo al cursore + Set Alignment, Keep Position Imposta allineamento mantenendo la posizione + Reset Text Transform Reimposta trasformazione testo + Replace Missing Fonts… Sostituisci font mancanti… + Replace Missing Fonts Sostituisci font mancanti + Remove Line Breaks Rimuovi interruzioni di riga + Remove Paragraph Breaks Rimuovi interruzioni di paragrafo + Break Text into Lines Dividi testo in righe + Break Text into Paragraphs Dividi testo in paragrafi + Break Text into Words Dividi testo in parole + Break Text into Glyphs Dividi testo in glifi + Reset Text Transform and Scale Reimposta trasformazione e scala testo + Set Alignment, Keep Position: Imposta allineamento mantenendo la posizione: + Break Text into: Dividi testo in: + Split at Cursor Dividi al cursore + Reset Transform Reimposta trasformazione + Shift-click: break into paragraphs Shift+clic: dividi in paragrafi + Alt-click: reset the horizontal scale too Alt+clic: reimposta anche la scala orizzontale +breakText Lines Righe +breakText Words Parole +breakText Glyphs Glifi +@msg Click in type with a Type tool to split it Fai clic nel testo con uno strumento Testo per dividerlo +@msg Too many type objects to change at once Troppi oggetti di testo da modificare in una volta +@msg Too many pieces to make at once Troppi pezzi da creare in una volta +@msg Select horizontal point or area type whose text fits Seleziona testo con punto o in area orizzontale il cui testo ci sta +@msg Nothing to split at the cursor Niente da dividere al cursore +@msg Select two or more one-line point type objects on one baseline to combine Seleziona due o più oggetti di testo con punto su una riga sulla stessa linea di base per combinarli +@msg No line breaks to remove Nessuna interruzione di riga da rimuovere +@msg No paragraph breaks to remove Nessuna interruzione di paragrafo da rimuovere +@msg Too many breaks to remove at once Troppe interruzioni da rimuovere in una volta +@msg Select point type Seleziona testo con punto +@msg The point type is aligned that way already Il testo con punto è già allineato così +@msg The point type has no transform to reset Il testo con punto non ha trasformazioni da reimpostare +@msg No fonts are missing Non mancano font +@msg the replacement font is not available il font sostitutivo non è disponibile +@msg missing `align` `align` mancante +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` deve essere lines, paragraphs, words o glyphs, non {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` deve essere "line" o "paragraph", non {other} +@msg `align` must be "left", "center" or "right", not {other} `align` deve essere "left", "center" o "right", non {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects Effetti tracciato + Reverse Direction Inverti direzione + Align to Corners and Ends Allinea ad angoli ed estremità + Count Numero + Max Count Numero massimo + Start Length Lunghezza iniziale + Start Percent Percentuale iniziale + Start Shape Forma iniziale + End Length Lunghezza finale + End Percent Percentuale finale + End Shape Forma finale + By Count Per numero + By Distance Per distanza + Both Entrambi + Shorter Than Length Più corte della lunghezza + Anchors Punti di ancoraggio + Dashes to Geometry Tratti in geometria + Dashes to Geometry… Tratti in geometria… + Add Anchors Aggiungi punti di ancoraggio + Add Anchors… Aggiungi punti di ancoraggio… + Smooth Anchors Smussa punti di ancoraggio + Smooth Anchors… Smussa punti di ancoraggio… + Simplify Anchors Semplifica punti di ancoraggio + Simplify Anchors… Semplifica punti di ancoraggio… + Split at Anchors Dividi ai punti di ancoraggio + Split at Anchors… Dividi ai punti di ancoraggio… + Extend Path… Estendi tracciato… + Close Path… Chiudi tracciato… + Retract Handles… Ritira maniglie… + Dash 1 Tratto 1 + Gap 1 Spazio 1 + Dash 2 Tratto 2 + Gap 2 Spazio 2 + Dash 3 Tratto 3 + Gap 3 Spazio 3 + Dash 4 Tratto 4 + Gap 4 Spazio 4 + Dash 5 Tratto 5 + Gap 5 Spazio 5 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 3372c767e..ea8546559 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3372,7 +3372,7 @@ gradient-kind Radial 円形 Client-side (.html) クライアントサイド (.html) Cmd Cmd Collect Links in a Separate Folder リンクを別フォルダーに収集 - Color (,) カラー (,) + Color (, or <) カラー (, または <) Color Group カラーグループ Color Library カラーライブラリ Color Management カラーマネジメント @@ -3467,7 +3467,7 @@ file-filter Templates テンプレート General › Placement › X 一般 › 位置 › X General › Thumbnails 一般 › サムネール Global Process Color, CMYK グローバルプロセスカラー、CMYK - Gradient (.) グラデーション (.) + Gradient (. or >) グラデーション (. または >) Graph Data グラフデータ Graph Type グラフの種類 Graph Design グラフのデザイン @@ -5575,3 +5575,111 @@ anchor Points: ポイント: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` `inkLimit`、`removeWhiteOverprint` または `overprintBlack` を指定してください @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first 印刷インキの修正は CMYK ドキュメントで使用できます。先にファイル › ドキュメントのカラーモード › CMYK カラーを選択してください @msg `{key}` must be 0..{max} `{key}` は 0..{max} にしてください +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool コーナーツール + Slide Anchor Along Path パスに沿ってアンカーポイントをスライド + Set Corners コーナーを設定 + Remove Corners コーナーを削除 + Corners selected: {count} 選択中のコーナー:{count} + Apply to Selected Anchors 選択中のアンカーポイントに適用 +@msg `radius` must be from 0 to 1000000 pt `radius` は 0 から 1000000 pt にしてください +@msg `t` must be a number from -0.98 to 0.98 `t` は -0.98 から 0.98 の数値にしてください +@msg choose anchors where two sides meet at an angle: only those take a corner 2 つの辺が角度をなして交わるアンカーポイントを選択してください:コーナーを作れるのはそこだけです +@msg give `anchor`, the index of the anchor to slide スライドするアンカーポイントの番号 `anchor` を指定してください +@msg that anchor can't slide: an end of an open path has nowhere to go そのアンカーポイントはスライドできません:オープンパスの端点には移動先がありません +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool 共有エッジ結合ツール + Merge Shared Edge 共有エッジを結合 + Keep Shared Points Together 共有ポイントをまとめて移動 + Shared Edges 共有エッジ + Coincidence: 一致の許容値: +@msg `a` and `b` must be two different paths `a` と `b` は異なる 2 つのパスにしてください +@msg `diameter` must be from 0.25 to 200 pt `diameter` は 0.25〜200 pt にしてください +@msg `tolerance` must be from 0 to 3 pt `tolerance` は 0〜3 pt にしてください +@msg `follow` must be "average", "a" or "b" `follow` は "average"、"a"、"b" のいずれかにしてください +@msg the paths have nothing to merge there ここには結合できるものがありません + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text テキストを分割 + Combine Point Text ポイント文字を結合 + Remove Breaks 改行を削除 + Split Text at Cursor カーソル位置でテキストを分割 + Set Alignment, Keep Position 位置を保って行揃えを設定 + Reset Text Transform テキストの変形をリセット + Replace Missing Fonts… 見つからないフォントを置換… + Replace Missing Fonts 見つからないフォントを置換 + Remove Line Breaks 強制改行を削除 + Remove Paragraph Breaks 段落の区切りを削除 + Break Text into Lines テキストを行に分割 + Break Text into Paragraphs テキストを段落に分割 + Break Text into Words テキストを単語に分割 + Break Text into Glyphs テキストを字形に分割 + Reset Text Transform and Scale テキストの変形と比率をリセット + Set Alignment, Keep Position: 位置を保って行揃えを設定: + Break Text into: テキストを分割: + Split at Cursor カーソル位置で分割 + Reset Transform 変形をリセット + Shift-click: break into paragraphs Shift+クリック:段落に分割 + Alt-click: reset the horizontal scale too Alt+クリック:水平比率もリセット +breakText Lines 行 +breakText Words 単語 +breakText Glyphs 字形 +@msg Click in type with a Type tool to split it 分割するには、文字ツールでテキスト内をクリックしてください +@msg Too many type objects to change at once 一度に変更するテキストオブジェクトが多すぎます +@msg Too many pieces to make at once 一度に作成する部分が多すぎます +@msg Select horizontal point or area type whose text fits テキストが収まっている横組みのポイント文字またはエリア内文字を選択してください +@msg Nothing to split at the cursor カーソル位置で分割するものがありません +@msg Select two or more one-line point type objects on one baseline to combine 結合するには、同じベースライン上にある 1 行のポイント文字オブジェクトを 2 つ以上選択してください +@msg No line breaks to remove 削除する強制改行がありません +@msg No paragraph breaks to remove 削除する段落の区切りがありません +@msg Too many breaks to remove at once 一度に削除する改行が多すぎます +@msg Select point type ポイント文字を選択してください +@msg The point type is aligned that way already ポイント文字はすでにその行揃えです +@msg The point type has no transform to reset ポイント文字にリセットする変形がありません +@msg No fonts are missing 見つからないフォントはありません +@msg the replacement font is not available 置換用のフォントを使用できません +@msg missing `align` `align` がありません +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` は lines、paragraphs、words、glyphs のいずれかにしてください({other} は使用できません) +@msg `kind` must be "line" or "paragraph", not {other} `kind` は "line" または "paragraph" にしてください({other} は使用できません) +@msg `align` must be "left", "center" or "right", not {other} `align` は "left"、"center"、"right" のいずれかにしてください({other} は使用できません) + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects パス効果 + Reverse Direction 方向を反転 + Align to Corners and Ends コーナーと先端に整列 + Count 個数 + Max Count 最大個数 + Start Length 始点の長さ + Start Percent 始点のパーセント + Start Shape 始点の形状 + End Length 終点の長さ + End Percent 終点のパーセント + End Shape 終点の形状 + By Count 個数で指定 + By Distance 距離で指定 + Both 両方 + Shorter Than Length 長さより短いもの + Anchors アンカーポイント + Dashes to Geometry 破線をジオメトリに + Dashes to Geometry… 破線をジオメトリに… + Add Anchors アンカーポイントを追加 + Add Anchors… アンカーポイントを追加… + Smooth Anchors アンカーポイントをスムーズに + Smooth Anchors… アンカーポイントをスムーズに… + Simplify Anchors アンカーポイントを単純化 + Simplify Anchors… アンカーポイントを単純化… + Split at Anchors アンカーポイントで分割 + Split at Anchors… アンカーポイントで分割… + Extend Path… パスを延長… + Close Path… パスを閉じる… + Retract Handles… ハンドルを収める… + Dash 1 線分 1 + Gap 1 間隔 1 + Dash 2 線分 2 + Gap 2 間隔 2 + Dash 3 線分 3 + Gap 3 間隔 3 + Dash 4 線分 4 + Gap 4 間隔 4 + Dash 5 線分 5 + Gap 5 間隔 5 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index d49e27455..58e6e80ba 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1061,12 +1061,12 @@ lighting Light სინათლე to set spacing and orientation — ინტერვალისა და ორიენტაციის დასაყენებლად Apply გამოყენება Change Screen Mode (F) ეკრანის რეჟიმის შეცვლა (F) - Color (,) ფერი (,) + Color (, or <) ფერი (, ან <) Draw Behind უკან ხატვა Draw Inside შიგნით ხატვა Draw Normal ჩვეულებრივი ხატვა Expand: გაშლა: - Gradient (.) გრადიენტი (.) + Gradient (. or >) გრადიენტი (. ან >) H: სიმ.: Modify შეცვლა Navigate ნავიგაცია @@ -5877,3 +5877,110 @@ anchor Points: წერტილები: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` მიუთითეთ `inkLimit`, `removeWhiteOverprint` ან `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first საბეჭდი მელნის შესწორებები მუშაობს CMYK დოკუმენტებზე: ჯერ აირჩიეთ ფაილი › დოკუმენტის ფერის რეჟიმი › CMYK ფერი @msg `{key}` must be 0..{max} `{key}` უნდა იყოს 0..{max} +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool კუთხეების ხელსაწყო + Slide Anchor Along Path საყრდენი წერტილის სრიალი ტრაექტორიაზე + Set Corners კუთხეების დაყენება + Remove Corners კუთხეების მოცილება + Corners selected: {count} მონიშნული კუთხეები: {count} + Apply to Selected Anchors მონიშნულ საყრდენ წერტილებზე გამოყენება +@msg `radius` must be from 0 to 1000000 pt `radius` უნდა იყოს 0-დან 1000000 pt-მდე +@msg `t` must be a number from -0.98 to 0.98 `t` უნდა იყოს რიცხვი -0.98-დან 0.98-მდე +@msg choose anchors where two sides meet at an angle: only those take a corner აირჩიეთ საყრდენი წერტილები, სადაც ორი გვერდი კუთხით ხვდება ერთმანეთს: მხოლოდ მათ შეუძლიათ კუთხის მიღება +@msg give `anchor`, the index of the anchor to slide მიუთითეთ `anchor`, სასრიალო საყრდენი წერტილის ინდექსი +@msg that anchor can't slide: an end of an open path has nowhere to go ეს საყრდენი წერტილი ვერ ისრიალებს: ღია ტრაექტორიის ბოლოს წასასვლელი არ აქვს +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool საერთო წიბოს შერწყმის ხელსაწყო + Merge Shared Edge საერთო წიბოს შერწყმა + Keep Shared Points Together საერთო წერტილების ერთად შენარჩუნება + Shared Edges საერთო წიბოები + Coincidence: დამთხვევა: +@msg `a` and `b` must be two different paths `a` და `b` ორი სხვადასხვა ტრაექტორია უნდა იყოს +@msg `diameter` must be from 0.25 to 200 pt `diameter` უნდა იყოს 0.25-დან 200 pt-მდე +@msg `tolerance` must be from 0 to 3 pt `tolerance` უნდა იყოს 0-დან 3 pt-მდე +@msg `follow` must be "average", "a" or "b" `follow` უნდა იყოს "average", "a" ან "b" +@msg the paths have nothing to merge there ტრაექტორიებს აქ შესარწყმელი არაფერი აქვთ + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text ტექსტის დაყოფა + Combine Point Text წერტილის ტექსტის გაერთიანება + Remove Breaks წყვეტების წაშლა + Split Text at Cursor ტექსტის დაყოფა კურსორთან + Set Alignment, Keep Position სწორების დაყენება მდებარეობის შენარჩუნებით + Reset Text Transform ტექსტის გარდაქმნის ჩამოყრა + Replace Missing Fonts… დაკარგული შრიფტების ჩანაცვლება… + Replace Missing Fonts დაკარგული შრიფტების ჩანაცვლება + Remove Line Breaks სტრიქონის წყვეტების წაშლა + Remove Paragraph Breaks აბზაცის წყვეტების წაშლა + Break Text into Lines ტექსტის დაყოფა სტრიქონებად + Break Text into Paragraphs ტექსტის დაყოფა აბზაცებად + Break Text into Words ტექსტის დაყოფა სიტყვებად + Break Text into Glyphs ტექსტის დაყოფა გლიფებად + Reset Text Transform and Scale ტექსტის გარდაქმნისა და მასშტაბის ჩამოყრა + Set Alignment, Keep Position: სწორების დაყენება მდებარეობის შენარჩუნებით: + Break Text into: ტექსტის დაყოფა: + Split at Cursor დაყოფა კურსორთან + Reset Transform გარდაქმნის ჩამოყრა + Shift-click: break into paragraphs Shift-დაწკაპუნება: დაყოფა აბზაცებად + Alt-click: reset the horizontal scale too Alt-დაწკაპუნება: ჰორიზონტალური მასშტაბის ჩამოყრაც +breakText Lines სტრიქონები +breakText Words სიტყვები +breakText Glyphs გლიფები +@msg Click in type with a Type tool to split it დასაყოფად დააწკაპუნეთ ტექსტში ტექსტის ხელსაწყოთი +@msg Too many type objects to change at once ერთდროულად შესაცვლელად ძალიან ბევრი ტექსტური ობიექტია +@msg Too many pieces to make at once ერთდროულად შესაქმნელად ძალიან ბევრი ნაწილია +@msg Select horizontal point or area type whose text fits აირჩიეთ ჰორიზონტალური წერტილის ან არის ტექსტი, რომლის ტექსტიც ეტევა +@msg Nothing to split at the cursor კურსორთან დასაყოფი არაფერია +@msg Select two or more one-line point type objects on one baseline to combine გასაერთიანებლად აირჩიეთ ერთ საბაზისო ხაზზე მდებარე ორი ან მეტი ერთსტრიქონიანი წერტილის ტექსტის ობიექტი +@msg No line breaks to remove წასაშლელი სტრიქონის წყვეტები არ არის +@msg No paragraph breaks to remove წასაშლელი აბზაცის წყვეტები არ არის +@msg Too many breaks to remove at once ერთდროულად წასაშლელად ძალიან ბევრი წყვეტაა +@msg Select point type აირჩიეთ წერტილის ტექსტი +@msg The point type is aligned that way already წერტილის ტექსტი უკვე ასეა სწორებული +@msg The point type has no transform to reset წერტილის ტექსტს ჩამოსაყრელი გარდაქმნა არ აქვს +@msg No fonts are missing დაკარგული შრიფტები არ არის +@msg the replacement font is not available ჩამნაცვლებელი შრიფტი მიუწვდომელია +@msg missing `align` `align` აკლია +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` უნდა იყოს lines, paragraphs, words ან glyphs და არა {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` უნდა იყოს "line" ან "paragraph" და არა {other} +@msg `align` must be "left", "center" or "right", not {other} `align` უნდა იყოს "left", "center" ან "right" და არა {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects ტრაექტორიის ეფექტები + Reverse Direction მიმართულების შებრუნება + Align to Corners and Ends კუთხეებთან და ბოლოებთან სწორება + Count რაოდენობა + Max Count მაქსიმალური რაოდენობა + Start Length საწყისი სიგრძე + Start Percent საწყისი პროცენტი + Start Shape საწყისი ფორმა + End Length საბოლოო სიგრძე + End Percent საბოლოო პროცენტი + End Shape საბოლოო ფორმა + By Count რაოდენობით + By Distance მანძილით + Both ორივე + Shorter Than Length სიგრძეზე მოკლე + Dashes to Geometry ტირეები გეომეტრიად + Dashes to Geometry… ტირეები გეომეტრიად… + Add Anchors საყრდენი წერტილების დამატება + Add Anchors… საყრდენი წერტილების დამატება… + Smooth Anchors საყრდენი წერტილების მოგლუვება + Smooth Anchors… საყრდენი წერტილების მოგლუვება… + Simplify Anchors საყრდენი წერტილების გამარტივება + Simplify Anchors… საყრდენი წერტილების გამარტივება… + Split at Anchors დაყოფა საყრდენ წერტილებზე + Split at Anchors… დაყოფა საყრდენ წერტილებზე… + Extend Path… ტრაექტორიის გაგრძელება… + Close Path… ტრაექტორიის შეკვრა… + Retract Handles… სახელურების შეკეცვა… + Dash 1 ტირე 1 + Gap 1 შუალედი 1 + Dash 2 ტირე 2 + Gap 2 შუალედი 2 + Dash 3 ტირე 3 + Gap 3 შუალედი 3 + Dash 4 ტირე 4 + Gap 4 შუალედი 4 + Dash 5 ტირე 5 + Gap 5 შუალედი 5 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 9f6043ec7..c31f6de8a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3358,7 +3358,7 @@ gradient-kind Radial 방사형 Client-side (.html) 클라이언트 측 (.html) Cmd Cmd Collect Links in a Separate Folder 링크를 별도 폴더에 모으기 - Color (,) 색상 (,) + Color (, or <) 색상 (, 또는 <) Color Group 색상 그룹 Color Library 색상 라이브러리 Color Management 색상 관리 @@ -3453,7 +3453,7 @@ file-filter Templates 템플릿 General › Placement › X 일반 › 배치 › X General › Thumbnails 일반 › 축소판 Global Process Color, CMYK 글로벌 프로세스 색상, CMYK - Gradient (.) 그레이디언트 (.) + Gradient (. or >) 그레이디언트 (. 또는 >) Graph Data 그래프 데이터 Graph Type 그래프 유형 Graph Design 그래프 디자인 @@ -5469,3 +5469,111 @@ anchor Points: 포인트: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` `inkLimit`, `removeWhiteOverprint` 또는 `overprintBlack`을 지정하십시오 @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first 인쇄 잉크 수정은 CMYK 문서에서 작동합니다. 먼저 파일 › 문서 색상 모드 › CMYK 색상을 선택하십시오 @msg `{key}` must be 0..{max} `{key}`은(는) 0..{max}이어야 합니다 +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool 모퉁이 도구 + Slide Anchor Along Path 패스를 따라 앵커 포인트 밀기 + Set Corners 모퉁이 설정 + Remove Corners 모퉁이 제거 + Corners selected: {count} 선택한 모퉁이: {count} + Apply to Selected Anchors 선택한 앵커 포인트에 적용 +@msg `radius` must be from 0 to 1000000 pt `radius`은(는) 0에서 1000000 pt 사이여야 합니다 +@msg `t` must be a number from -0.98 to 0.98 `t`은(는) -0.98에서 0.98 사이의 숫자여야 합니다 +@msg choose anchors where two sides meet at an angle: only those take a corner 두 변이 각을 이루며 만나는 앵커 포인트를 선택하세요. 그런 포인트만 모퉁이를 만들 수 있습니다 +@msg give `anchor`, the index of the anchor to slide 밀 앵커 포인트의 인덱스 `anchor`를 지정하세요 +@msg that anchor can't slide: an end of an open path has nowhere to go 이 앵커 포인트는 밀 수 없습니다. 열린 패스의 끝은 갈 곳이 없습니다 +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool 공유 가장자리 병합 도구 + Merge Shared Edge 공유 가장자리 병합 + Keep Shared Points Together 공유 포인트 함께 유지 + Shared Edges 공유 가장자리 + Coincidence: 일치 허용치: +@msg `a` and `b` must be two different paths `a`와 `b`는 서로 다른 두 패스여야 합니다 +@msg `diameter` must be from 0.25 to 200 pt `diameter`는 0.25~200 pt여야 합니다 +@msg `tolerance` must be from 0 to 3 pt `tolerance`는 0~3 pt여야 합니다 +@msg `follow` must be "average", "a" or "b" `follow`는 "average", "a" 또는 "b"여야 합니다 +@msg the paths have nothing to merge there 여기에는 병합할 패스 부분이 없습니다 + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text 텍스트 나누기 + Combine Point Text 포인트 문자 결합 + Remove Breaks 나누기 제거 + Split Text at Cursor 커서 위치에서 텍스트 나누기 + Set Alignment, Keep Position 위치를 유지하며 정렬 설정 + Reset Text Transform 텍스트 변형 재설정 + Replace Missing Fonts… 누락된 글꼴 바꾸기… + Replace Missing Fonts 누락된 글꼴 바꾸기 + Remove Line Breaks 강제 줄 바꿈 제거 + Remove Paragraph Breaks 단락 나누기 제거 + Break Text into Lines 텍스트를 줄로 나누기 + Break Text into Paragraphs 텍스트를 단락으로 나누기 + Break Text into Words 텍스트를 단어로 나누기 + Break Text into Glyphs 텍스트를 글리프로 나누기 + Reset Text Transform and Scale 텍스트 변형 및 비율 재설정 + Set Alignment, Keep Position: 위치를 유지하며 정렬 설정: + Break Text into: 텍스트 나누기: + Split at Cursor 커서 위치에서 나누기 + Reset Transform 변형 재설정 + Shift-click: break into paragraphs Shift-클릭: 단락으로 나누기 + Alt-click: reset the horizontal scale too Alt-클릭: 가로 비율도 재설정 +breakText Lines 줄 +breakText Words 단어 +breakText Glyphs 글리프 +@msg Click in type with a Type tool to split it 나누려면 문자 도구로 텍스트 안을 클릭하십시오 +@msg Too many type objects to change at once 한 번에 변경할 문자 오브젝트가 너무 많습니다 +@msg Too many pieces to make at once 한 번에 만들 조각이 너무 많습니다 +@msg Select horizontal point or area type whose text fits 텍스트가 모두 들어가는 가로 포인트 문자 또는 영역 문자를 선택하십시오 +@msg Nothing to split at the cursor 커서 위치에서 나눌 내용이 없습니다 +@msg Select two or more one-line point type objects on one baseline to combine 결합하려면 같은 기준선에 있는 한 줄짜리 포인트 문자 오브젝트를 두 개 이상 선택하십시오 +@msg No line breaks to remove 제거할 강제 줄 바꿈이 없습니다 +@msg No paragraph breaks to remove 제거할 단락 나누기가 없습니다 +@msg Too many breaks to remove at once 한 번에 제거할 나누기가 너무 많습니다 +@msg Select point type 포인트 문자를 선택하십시오 +@msg The point type is aligned that way already 포인트 문자가 이미 그렇게 정렬되어 있습니다 +@msg The point type has no transform to reset 포인트 문자에 재설정할 변형이 없습니다 +@msg No fonts are missing 누락된 글꼴이 없습니다 +@msg the replacement font is not available 대체 글꼴을 사용할 수 없습니다 +@msg missing `align` `align`이 없습니다 +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by`는 lines, paragraphs, words 또는 glyphs여야 합니다({other}는 사용할 수 없음) +@msg `kind` must be "line" or "paragraph", not {other} `kind`는 "line" 또는 "paragraph"여야 합니다({other}는 사용할 수 없음) +@msg `align` must be "left", "center" or "right", not {other} `align`은 "left", "center" 또는 "right"여야 합니다({other}는 사용할 수 없음) + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects 패스 효과 + Reverse Direction 방향 반전 + Align to Corners and Ends 모퉁이와 끝에 정렬 + Count 개수 + Max Count 최대 개수 + Start Length 시작 길이 + Start Percent 시작 퍼센트 + Start Shape 시작 모양 + End Length 끝 길이 + End Percent 끝 퍼센트 + End Shape 끝 모양 + By Count 개수 기준 + By Distance 거리 기준 + Both 둘 다 + Shorter Than Length 길이보다 짧은 핸들 + Anchors 앵커 포인트 + Dashes to Geometry 대시를 도형으로 + Dashes to Geometry… 대시를 도형으로… + Add Anchors 앵커 포인트 추가 + Add Anchors… 앵커 포인트 추가… + Smooth Anchors 앵커 포인트 매끄럽게 + Smooth Anchors… 앵커 포인트 매끄럽게… + Simplify Anchors 앵커 포인트 단순화 + Simplify Anchors… 앵커 포인트 단순화… + Split at Anchors 앵커 포인트에서 분할 + Split at Anchors… 앵커 포인트에서 분할… + Extend Path… 패스 연장… + Close Path… 패스 닫기… + Retract Handles… 핸들 접기… + Dash 1 대시 1 + Gap 1 간격 1 + Dash 2 대시 2 + Gap 2 간격 2 + Dash 3 대시 3 + Gap 3 간격 3 + Dash 4 대시 4 + Gap 4 간격 4 + Dash 5 대시 5 + Gap 5 간격 5 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 52f392fbf..6f74d869a 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1141,12 +1141,12 @@ lighting Light Licht to set spacing and orientation om de tussenruimte en de oriëntatie in te stellen Apply Toepassen Change Screen Mode (F) Schermmodus wisselen (F) - Color (,) Kleur (,) + Color (, or <) Kleur (, of <) Draw Behind Achter tekenen Draw Inside Binnen tekenen Draw Normal Normaal tekenen Expand: Uitvouwen: - Gradient (.) Verloop (.) + Gradient (. or >) Verloop (. of >) H: H: Modify Wijzigen Navigate Navigeren @@ -5835,3 +5835,110 @@ anchor Points: Punten: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` geef `inkLimit`, `removeWhiteOverprint` of `overprintBlack` op @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Drukinktcorrecties werken in CMYK-documenten: kies eerst Bestand › Documentkleurmodus › CMYK-kleur @msg `{key}` must be 0..{max} `{key}` moet 0..{max} zijn +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Hoekengereedschap + Slide Anchor Along Path Ankerpunt langs pad schuiven + Set Corners Hoeken instellen + Remove Corners Hoeken verwijderen + Corners selected: {count} Geselecteerde hoeken: {count} + Apply to Selected Anchors Toepassen op geselecteerde ankerpunten +@msg `radius` must be from 0 to 1000000 pt `radius` moet van 0 tot 1000000 pt zijn +@msg `t` must be a number from -0.98 to 0.98 `t` moet een getal van -0.98 tot 0.98 zijn +@msg choose anchors where two sides meet at an angle: only those take a corner kies ankerpunten waar twee zijden elkaar in een hoek ontmoeten: alleen die kunnen een hoek krijgen +@msg give `anchor`, the index of the anchor to slide geef `anchor` op, de index van het ankerpunt dat moet schuiven +@msg that anchor can't slide: an end of an open path has nowhere to go dat ankerpunt kan niet schuiven: het einde van een open pad kan nergens heen +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Gereedschap Gedeelde rand samenvoegen + Merge Shared Edge Gedeelde rand samenvoegen + Keep Shared Points Together Gedeelde punten bij elkaar houden + Shared Edges Gedeelde randen + Coincidence: Samenvallen: +@msg `a` and `b` must be two different paths `a` en `b` moeten twee verschillende paden zijn +@msg `diameter` must be from 0.25 to 200 pt `diameter` moet van 0.25 tot 200 pt zijn +@msg `tolerance` must be from 0 to 3 pt `tolerance` moet van 0 tot 3 pt zijn +@msg `follow` must be "average", "a" or "b" `follow` moet "average", "a" of "b" zijn +@msg the paths have nothing to merge there de paden hebben hier niets om samen te voegen + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Tekst splitsen + Combine Point Text Punttekst combineren + Remove Breaks Einden verwijderen + Split Text at Cursor Tekst splitsen bij cursor + Set Alignment, Keep Position Uitlijning instellen, positie behouden + Reset Text Transform Teksttransformatie herstellen + Replace Missing Fonts… Ontbrekende lettertypen vervangen… + Replace Missing Fonts Ontbrekende lettertypen vervangen + Remove Line Breaks Regeleinden verwijderen + Remove Paragraph Breaks Alinea-einden verwijderen + Break Text into Lines Tekst splitsen in regels + Break Text into Paragraphs Tekst splitsen in alinea's + Break Text into Words Tekst splitsen in woorden + Break Text into Glyphs Tekst splitsen in glyphs + Reset Text Transform and Scale Teksttransformatie en schaal herstellen + Set Alignment, Keep Position: Uitlijning instellen, positie behouden: + Break Text into: Tekst splitsen in: + Split at Cursor Splitsen bij cursor + Reset Transform Transformatie herstellen + Shift-click: break into paragraphs Shift-klik: splitsen in alinea's + Alt-click: reset the horizontal scale too Alt-klik: ook de horizontale schaal herstellen +breakText Lines Regels +breakText Words Woorden +breakText Glyphs Glyphs +@msg Click in type with a Type tool to split it Klik met een Type-gereedschap in tekst om deze te splitsen +@msg Too many type objects to change at once Te veel tekstobjecten om in één keer te wijzigen +@msg Too many pieces to make at once Te veel stukken om in één keer te maken +@msg Select horizontal point or area type whose text fits Selecteer horizontale punt- of vlaktekst waarvan de tekst past +@msg Nothing to split at the cursor Niets te splitsen bij de cursor +@msg Select two or more one-line point type objects on one baseline to combine Selecteer twee of meer punttekstobjecten van één regel op één basislijn om ze te combineren +@msg No line breaks to remove Geen regeleinden om te verwijderen +@msg No paragraph breaks to remove Geen alinea-einden om te verwijderen +@msg Too many breaks to remove at once Te veel einden om in één keer te verwijderen +@msg Select point type Selecteer punttekst +@msg The point type is aligned that way already De punttekst is al zo uitgelijnd +@msg The point type has no transform to reset De punttekst heeft geen transformatie om te herstellen +@msg No fonts are missing Er ontbreken geen lettertypen +@msg the replacement font is not available het vervangende lettertype is niet beschikbaar +@msg missing `align` `align` ontbreekt +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` moet lines, paragraphs, words of glyphs zijn, niet {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` moet "line" of "paragraph" zijn, niet {other} +@msg `align` must be "left", "center" or "right", not {other} `align` moet "left", "center" of "right" zijn, niet {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects Padeffecten + Reverse Direction Richting omkeren + Align to Corners and Ends Uitlijnen op hoeken en uiteinden + Count Aantal + Max Count Maximumaantal + Start Length Beginlengte + Start Percent Beginpercentage + Start Shape Beginvorm + End Length Eindlengte + End Percent Eindpercentage + End Shape Eindvorm + By Count Op aantal + By Distance Op afstand + Both Beide + Shorter Than Length Korter dan lengte + Dashes to Geometry Streepjes naar geometrie + Dashes to Geometry… Streepjes naar geometrie… + Add Anchors Ankerpunten toevoegen + Add Anchors… Ankerpunten toevoegen… + Smooth Anchors Ankerpunten gladmaken + Smooth Anchors… Ankerpunten gladmaken… + Simplify Anchors Ankerpunten vereenvoudigen + Simplify Anchors… Ankerpunten vereenvoudigen… + Split at Anchors Splitsen bij ankerpunten + Split at Anchors… Splitsen bij ankerpunten… + Extend Path… Pad verlengen… + Close Path… Pad sluiten… + Retract Handles… Grepen intrekken… + Dash 1 Streepje 1 + Gap 1 Tussenruimte 1 + Dash 2 Streepje 2 + Gap 2 Tussenruimte 2 + Dash 3 Streepje 3 + Gap 3 Tussenruimte 3 + Dash 4 Streepje 4 + Gap 4 Tussenruimte 4 + Dash 5 Streepje 5 + Gap 5 Tussenruimte 5 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 00ba81665..7ed247bfe 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1140,12 +1140,12 @@ lighting Light Światło to set spacing and orientation aby ustawić odstęp i orientację Apply Zastosuj Change Screen Mode (F) Zmień tryb ekranu (F) - Color (,) Kolor (,) + Color (, or <) Kolor (, lub <) Draw Behind Rysuj pod spodem Draw Inside Rysuj wewnątrz Draw Normal Rysuj normalnie Expand: Rozszerz: - Gradient (.) Gradient (.) + Gradient (. or >) Gradient (. lub >) H: W: Modify Modyfikuj Navigate Nawigacja @@ -5742,3 +5742,111 @@ anchor Points: Punkty: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` podaj `inkLimit`, `removeWhiteOverprint` lub `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Poprawki farb drukarskich działają w dokumentach CMYK: najpierw wybierz Plik › Tryb koloru dokumentu › Kolor CMYK @msg `{key}` must be 0..{max} `{key}` musi wynosić 0..{max} +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Narzędzie Narożniki + Slide Anchor Along Path Przesuń punkt kontrolny wzdłuż ścieżki + Set Corners Ustaw narożniki + Remove Corners Usuń narożniki + Corners selected: {count} Zaznaczone narożniki: {count} + Apply to Selected Anchors Zastosuj do zaznaczonych punktów kontrolnych +@msg `radius` must be from 0 to 1000000 pt `radius` musi wynosić od 0 do 1000000 pt +@msg `t` must be a number from -0.98 to 0.98 `t` musi być liczbą od -0.98 do 0.98 +@msg choose anchors where two sides meet at an angle: only those take a corner wybierz punkty kontrolne, w których dwa boki spotykają się pod kątem: tylko one mogą mieć narożnik +@msg give `anchor`, the index of the anchor to slide podaj `anchor`, indeks punktu kontrolnego do przesunięcia +@msg that anchor can't slide: an end of an open path has nowhere to go tego punktu kontrolnego nie można przesunąć: koniec otwartej ścieżki nie ma dokąd pójść +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Narzędzie Scal wspólną krawędź + Merge Shared Edge Scal wspólną krawędź + Keep Shared Points Together Trzymaj wspólne punkty razem + Shared Edges Wspólne krawędzie + Coincidence: Zbieżność: +@msg `a` and `b` must be two different paths `a` i `b` muszą być dwiema różnymi ścieżkami +@msg `diameter` must be from 0.25 to 200 pt `diameter` musi mieć od 0.25 do 200 pt +@msg `tolerance` must be from 0 to 3 pt `tolerance` musi mieć od 0 do 3 pt +@msg `follow` must be "average", "a" or "b" `follow` musi mieć wartość "average", "a" lub "b" +@msg the paths have nothing to merge there ścieżki nie mają tu nic do scalenia + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Podziel tekst + Combine Point Text Połącz tekst punktowy + Remove Breaks Usuń podziały + Split Text at Cursor Podziel tekst w miejscu kursora + Set Alignment, Keep Position Ustaw wyrównanie, zachowaj położenie + Reset Text Transform Resetuj przekształcenie tekstu + Replace Missing Fonts… Zastąp brakujące czcionki… + Replace Missing Fonts Zastąp brakujące czcionki + Remove Line Breaks Usuń podziały wiersza + Remove Paragraph Breaks Usuń podziały akapitu + Break Text into Lines Podziel tekst na wiersze + Break Text into Paragraphs Podziel tekst na akapity + Break Text into Words Podziel tekst na słowa + Break Text into Glyphs Podziel tekst na glify + Reset Text Transform and Scale Resetuj przekształcenie i skalę tekstu + Set Alignment, Keep Position: Ustaw wyrównanie, zachowaj położenie: + Break Text into: Podziel tekst na: + Split at Cursor Podziel w miejscu kursora + Reset Transform Resetuj przekształcenie + Shift-click: break into paragraphs Kliknięcie z Shift: podziel na akapity + Alt-click: reset the horizontal scale too Kliknięcie z Alt: resetuj także skalę poziomą +breakText Lines Wiersze +breakText Words Słowa +breakText Glyphs Glify +@msg Click in type with a Type tool to split it Kliknij w tekst narzędziem Tekst, aby go podzielić +@msg Too many type objects to change at once Zbyt wiele obiektów tekstowych do zmiany naraz +@msg Too many pieces to make at once Zbyt wiele części do utworzenia naraz +@msg Select horizontal point or area type whose text fits Zaznacz poziomy tekst punktowy lub akapitowy, którego tekst się mieści +@msg Nothing to split at the cursor Nie ma nic do podzielenia w miejscu kursora +@msg Select two or more one-line point type objects on one baseline to combine Zaznacz co najmniej dwa jednowierszowe obiekty tekstu punktowego na jednej linii bazowej, aby je połączyć +@msg No line breaks to remove Brak podziałów wiersza do usunięcia +@msg No paragraph breaks to remove Brak podziałów akapitu do usunięcia +@msg Too many breaks to remove at once Zbyt wiele podziałów do usunięcia naraz +@msg Select point type Zaznacz tekst punktowy +@msg The point type is aligned that way already Tekst punktowy jest już tak wyrównany +@msg The point type has no transform to reset Tekst punktowy nie ma przekształcenia do zresetowania +@msg No fonts are missing Nie brakuje żadnych czcionek +@msg the replacement font is not available czcionka zastępcza jest niedostępna +@msg missing `align` brak `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` musi mieć wartość lines, paragraphs, words lub glyphs, a nie {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` musi mieć wartość "line" lub "paragraph", a nie {other} +@msg `align` must be "left", "center" or "right", not {other} `align` musi mieć wartość "left", "center" lub "right", a nie {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects Efekty ścieżki + Reverse Direction Odwróć kierunek + Align to Corners and Ends Wyrównaj do narożników i końców + Count Liczba + Max Count Maksymalna liczba + Start Length Długość na początku + Start Percent Procent na początku + Start Shape Kształt na początku + End Length Długość na końcu + End Percent Procent na końcu + End Shape Kształt na końcu + By Count Według liczby + By Distance Według odległości + Both Oba + Shorter Than Length Krótsze niż długość + Anchors Punkty kontrolne + Dashes to Geometry Kreski jako geometria + Dashes to Geometry… Kreski jako geometria… + Add Anchors Dodaj punkty kontrolne + Add Anchors… Dodaj punkty kontrolne… + Smooth Anchors Wygładź punkty kontrolne + Smooth Anchors… Wygładź punkty kontrolne… + Simplify Anchors Uprość punkty kontrolne + Simplify Anchors… Uprość punkty kontrolne… + Split at Anchors Podziel w punktach kontrolnych + Split at Anchors… Podziel w punktach kontrolnych… + Extend Path… Przedłuż ścieżkę… + Close Path… Zamknij ścieżkę… + Retract Handles… Schowaj uchwyty… + Dash 1 Kreska 1 + Gap 1 Odstęp 1 + Dash 2 Kreska 2 + Gap 2 Odstęp 2 + Dash 3 Kreska 3 + Gap 3 Odstęp 3 + Dash 4 Kreska 4 + Gap 4 Odstęp 4 + Dash 5 Kreska 5 + Gap 5 Odstęp 5 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b7512ae35..ce7d8ae2f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4334,7 +4334,7 @@ axis Both Ambos Client-side (.html) Lado do Cliente (.html) Cmd Cmd Collect Links in a Separate Folder Reunir Links em uma Pasta Separada - Color (,) Cor (,) + Color (, or <) Cor (, ou <) Color bars Barras de cores Color Group Grupo de Cores Color Library Biblioteca de Cores @@ -4407,7 +4407,7 @@ axis Both Ambos General › Placement › X Geral › Posicionamento › X General › Thumbnails Geral › Miniaturas Global Process Color, CMYK Cor de Processo Global, CMYK - Gradient (.) Gradiente (.) + Gradient (. or >) Gradiente (. ou >) Graph Data Dados do Gráfico Graph Type Tipo de Gráfico New Design Novo Design @@ -5805,3 +5805,112 @@ anchor Points: Pontos: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` informe `inkLimit`, `removeWhiteOverprint` ou `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first As Correções de Tinta para Impressão funcionam em documentos CMYK: escolha antes Arquivo › Modo de Cor do Documento › Cor CMYK @msg `{key}` must be 0..{max} `{key}` deve ser 0..{max} +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Ferramenta Cantos + Slide Anchor Along Path Deslizar Ponto de Ancoragem pelo Caminho + Set Corners Definir Cantos + Remove Corners Remover Cantos + Corners selected: {count} Cantos selecionados: {count} + Apply to Selected Anchors Aplicar aos Pontos de Ancoragem Selecionados +@msg `radius` must be from 0 to 1000000 pt `radius` deve ser de 0 a 1000000 pt +@msg `t` must be a number from -0.98 to 0.98 `t` deve ser um número de -0.98 a 0.98 +@msg choose anchors where two sides meet at an angle: only those take a corner escolha pontos de ancoragem onde dois lados se encontram em ângulo: só esses aceitam um canto +@msg give `anchor`, the index of the anchor to slide informe `anchor`, o índice do ponto de ancoragem a deslizar +@msg that anchor can't slide: an end of an open path has nowhere to go esse ponto de ancoragem não pode deslizar: a extremidade de um caminho aberto não tem para onde ir +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Ferramenta Mesclar Borda Compartilhada + Merge Shared Edge Mesclar Borda Compartilhada + Keep Shared Points Together Manter Pontos Compartilhados Juntos + Shared Edges Bordas Compartilhadas + Coincidence: Coincidência: +@msg `a` and `b` must be two different paths `a` e `b` devem ser dois caminhos diferentes +@msg `diameter` must be from 0.25 to 200 pt `diameter` deve ser de 0.25 a 200 pt +@msg `tolerance` must be from 0 to 3 pt `tolerance` deve ser de 0 a 3 pt +@msg `follow` must be "average", "a" or "b" `follow` deve ser "average", "a" ou "b" +@msg the paths have nothing to merge there os caminhos não têm nada a mesclar aqui + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Dividir Texto + Combine Point Text Combinar Texto de Ponto + Remove Breaks Remover Quebras + Split Text at Cursor Dividir Texto no Cursor + Set Alignment, Keep Position Definir Alinhamento, Manter Posição + Reset Text Transform Redefinir Transformação do Texto + Replace Missing Fonts… Substituir Fontes Ausentes… + Replace Missing Fonts Substituir Fontes Ausentes + Remove Line Breaks Remover Quebras de Linha + Remove Paragraph Breaks Remover Quebras de Parágrafo + Break Text into Lines Dividir Texto em Linhas + Break Text into Paragraphs Dividir Texto em Parágrafos + Break Text into Words Dividir Texto em Palavras + Break Text into Glyphs Dividir Texto em Glifos + Reset Text Transform and Scale Redefinir Transformação e Escala do Texto + Set Alignment, Keep Position: Definir Alinhamento, Manter Posição: + Break Text into: Dividir Texto em: + Split at Cursor Dividir no Cursor + Reset Transform Redefinir Transformação + Shift-click: break into paragraphs Clique com Shift: dividir em parágrafos + Alt-click: reset the horizontal scale too Alt-clique: redefinir também a escala horizontal +breakText Lines Linhas +breakText Words Palavras +breakText Glyphs Glifos +@msg Click in type with a Type tool to split it Clique em um texto com uma ferramenta Texto para dividi-lo +@msg Too many type objects to change at once Objetos de texto demais para alterar de uma vez +@msg Too many pieces to make at once Partes demais para criar de uma vez +@msg Select horizontal point or area type whose text fits Selecione texto de ponto ou de área horizontal cujo texto caiba +@msg Nothing to split at the cursor Nada a dividir no cursor +@msg Select two or more one-line point type objects on one baseline to combine Selecione dois ou mais objetos de texto de ponto de uma linha na mesma linha de base para combiná-los +@msg No line breaks to remove Nenhuma quebra de linha a remover +@msg No paragraph breaks to remove Nenhuma quebra de parágrafo a remover +@msg Too many breaks to remove at once Quebras demais para remover de uma vez +@msg Select point type Selecione texto de ponto +@msg The point type is aligned that way already O texto de ponto já está alinhado assim +@msg The point type has no transform to reset O texto de ponto não tem transformação a redefinir +@msg No fonts are missing Nenhuma fonte está ausente +@msg the replacement font is not available a fonte substituta não está disponível +@msg missing `align` `align` ausente +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` deve ser lines, paragraphs, words ou glyphs, não {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` deve ser "line" ou "paragraph", não {other} +@msg `align` must be "left", "center" or "right", not {other} `align` deve ser "left", "center" ou "right", não {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Distance Distância + Mode Modo + Path Effects Efeitos de Caminho + Reverse Direction Inverter Direção + Align to Corners and Ends Alinhar aos Cantos e Extremidades + Count Quantidade + Max Count Quantidade Máxima + Start Length Comprimento Inicial + Start Percent Porcentagem Inicial + Start Shape Forma Inicial + End Length Comprimento Final + End Percent Porcentagem Final + End Shape Forma Final + By Count Por Quantidade + By Distance Por Distância + Both Ambos + Shorter Than Length Mais Curtas que o Comprimento + Dashes to Geometry Traços em Geometria + Dashes to Geometry… Traços em Geometria… + Add Anchors Adicionar Pontos de Ancoragem + Add Anchors… Adicionar Pontos de Ancoragem… + Smooth Anchors Suavizar Pontos de Ancoragem + Smooth Anchors… Suavizar Pontos de Ancoragem… + Simplify Anchors Simplificar Pontos de Ancoragem + Simplify Anchors… Simplificar Pontos de Ancoragem… + Split at Anchors Dividir nos Pontos de Ancoragem + Split at Anchors… Dividir nos Pontos de Ancoragem… + Extend Path… Estender Caminho… + Close Path… Fechar Caminho… + Retract Handles… Recolher Alças… + Dash 1 Traço 1 + Gap 1 Lacuna 1 + Dash 2 Traço 2 + Gap 2 Lacuna 2 + Dash 3 Traço 3 + Gap 3 Lacuna 3 + Dash 4 Traço 4 + Gap 4 Lacuna 4 + Dash 5 Traço 5 + Gap 5 Lacuna 5 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 920691e13..3adbaed22 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -511,7 +511,7 @@ font-technology Variable Вариативные Collect in New Layer Собрать в новый слой Collect Links in a Separate Folder Собрать связи в отдельную папку Color Цвет - Color (,) Цвет (,) + Color (, or <) Цвет (, или <) Color Adjustments Настройки цвета Color bars Цветные полосы Color blindness – Deuteranopia-type Дальтонизм — дейтеранопия @@ -1285,7 +1285,7 @@ file-filter Templates Шаблоны GPL palettes keep solid colours only, as RGB. Палитры GPL сохраняют только сплошные цвета в RGB. GPU Performance Производительность GPU Gradient Градиент - Gradient (.) Градиент (.) + Gradient (. or >) Градиент (. или >) Gradient across stroke Градиент поперёк обводки Gradient along stroke Градиент вдоль обводки Gradient and Mesh Resolution: Разрешение градиентов и сеток: @@ -5865,3 +5865,110 @@ anchor Points: Точки: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` задайте `inkLimit`, `removeWhiteOverprint` или `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Исправления печатных красок работают в документах CMYK: сначала выберите Файл › Цветовой режим документа › Цвет CMYK @msg `{key}` must be 0..{max} `{key}` должен быть 0..{max} +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Инструмент «Углы» + Slide Anchor Along Path Сдвинуть опорную точку вдоль контура + Set Corners Задать углы + Remove Corners Убрать углы + Corners selected: {count} Выбрано углов: {count} + Apply to Selected Anchors Применить к выбранным опорным точкам +@msg `radius` must be from 0 to 1000000 pt `radius` должен быть от 0 до 1000000 pt +@msg `t` must be a number from -0.98 to 0.98 `t` должен быть числом от -0.98 до 0.98 +@msg choose anchors where two sides meet at an angle: only those take a corner выберите опорные точки, где две стороны сходятся под углом: только они принимают угол +@msg give `anchor`, the index of the anchor to slide укажите `anchor`, индекс опорной точки для сдвига +@msg that anchor can't slide: an end of an open path has nowhere to go эту опорную точку нельзя сдвинуть: концу открытого контура некуда двигаться +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Инструмент «Слияние общего края» + Merge Shared Edge Объединить общий край + Keep Shared Points Together Держать общие точки вместе + Shared Edges Общие края + Coincidence: Совпадение: +@msg `a` and `b` must be two different paths `a` и `b` должны быть двумя разными контурами +@msg `diameter` must be from 0.25 to 200 pt `diameter` должно быть от 0.25 до 200 pt +@msg `tolerance` must be from 0 to 3 pt `tolerance` должно быть от 0 до 3 pt +@msg `follow` must be "average", "a" or "b" `follow` должно быть "average", "a" или "b" +@msg the paths have nothing to merge there здесь у контуров нечего объединять + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Разбить текст + Combine Point Text Объединить текст из точки + Remove Breaks Удалить разрывы + Split Text at Cursor Разбить текст у курсора + Set Alignment, Keep Position Задать выравнивание, сохранив положение + Reset Text Transform Сбросить трансформацию текста + Replace Missing Fonts… Заменить отсутствующие шрифты… + Replace Missing Fonts Заменить отсутствующие шрифты + Remove Line Breaks Удалить разрывы строк + Remove Paragraph Breaks Удалить разрывы абзацев + Break Text into Lines Разбить текст на строки + Break Text into Paragraphs Разбить текст на абзацы + Break Text into Words Разбить текст на слова + Break Text into Glyphs Разбить текст на глифы + Reset Text Transform and Scale Сбросить трансформацию и масштаб текста + Set Alignment, Keep Position: Выравнивание с сохранением положения: + Break Text into: Разбить текст на: + Split at Cursor Разбить у курсора + Reset Transform Сбросить трансформацию + Shift-click: break into paragraphs Shift+щелчок: разбить на абзацы + Alt-click: reset the horizontal scale too Alt+щелчок: сбросить и масштаб по горизонтали +breakText Lines Строки +breakText Words Слова +breakText Glyphs Глифы +@msg Click in type with a Type tool to split it Щёлкните в тексте инструментом «Текст», чтобы разбить его +@msg Too many type objects to change at once Слишком много текстовых объектов для изменения за один раз +@msg Too many pieces to make at once Слишком много частей для создания за один раз +@msg Select horizontal point or area type whose text fits Выделите горизонтальный текст из точки или в области, текст которого помещается +@msg Nothing to split at the cursor У курсора нечего разбивать +@msg Select two or more one-line point type objects on one baseline to combine Выделите два или более однострочных объекта текста из точки на одной базовой линии, чтобы объединить их +@msg No line breaks to remove Нет разрывов строк для удаления +@msg No paragraph breaks to remove Нет разрывов абзацев для удаления +@msg Too many breaks to remove at once Слишком много разрывов для удаления за один раз +@msg Select point type Выделите текст из точки +@msg The point type is aligned that way already Текст из точки уже так выровнен +@msg The point type has no transform to reset У текста из точки нет трансформации для сброса +@msg No fonts are missing Отсутствующих шрифтов нет +@msg the replacement font is not available шрифт для замены недоступен +@msg missing `align` не указан `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` должен быть lines, paragraphs, words или glyphs, а не {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` должен быть "line" или "paragraph", а не {other} +@msg `align` must be "left", "center" or "right", not {other} `align` должен быть "left", "center" или "right", а не {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects Эффекты контура + Reverse Direction Обратить направление + Align to Corners and Ends Выровнять по углам и концам + Count Количество + Max Count Наибольшее количество + Start Length Длина в начале + Start Percent Проценты в начале + Start Shape Форма в начале + End Length Длина в конце + End Percent Проценты в конце + End Shape Форма в конце + By Count По количеству + By Distance По расстоянию + Both Оба + Shorter Than Length Короче длины + Dashes to Geometry Штрихи в геометрию + Dashes to Geometry… Штрихи в геометрию… + Add Anchors Добавить опорные точки + Add Anchors… Добавить опорные точки… + Smooth Anchors Сгладить опорные точки + Smooth Anchors… Сгладить опорные точки… + Simplify Anchors Упростить опорные точки + Simplify Anchors… Упростить опорные точки… + Split at Anchors Разрезать в опорных точках + Split at Anchors… Разрезать в опорных точках… + Extend Path… Продлить контур… + Close Path… Замкнуть контур… + Retract Handles… Убрать маркеры… + Dash 1 Штрих 1 + Gap 1 Зазор 1 + Dash 2 Штрих 2 + Gap 2 Зазор 2 + Dash 3 Штрих 3 + Gap 3 Зазор 3 + Dash 4 Штрих 4 + Gap 4 Зазор 4 + Dash 5 Штрих 5 + Gap 5 Зазор 5 diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 71c5a0910..42bffe7be 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -501,7 +501,7 @@ font-technology Variable Variabla Collect in New Layer Samla i nytt lager Collect Links in a Separate Folder Samla länkar i en separat mapp Color Färg - Color (,) Färg (,) + Color (, or <) Färg (, eller <) Color Adjustments Färgjusteringar Color bars Färgstaplar Color blindness – Deuteranopia-type Färgblindhet – deuteranopi @@ -1242,7 +1242,7 @@ font-technology Variable Variabla GPL palettes keep solid colours only, as RGB. GPL-paletter sparar bara enfärgade färger, som RGB. GPU Performance GPU-prestanda Gradient Gradient - Gradient (.) Gradient (.) + Gradient (. or >) Gradient (. eller >) Gradient across stroke Gradient tvärs över konturen Gradient along stroke Gradient längs konturen Gradient and Mesh Resolution: Upplösning för gradienter och nät: @@ -5943,3 +5943,110 @@ shape Open Öppen @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` ange `inkLimit`, `removeWhiteOverprint` eller `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Tryckfärgskorrigeringar fungerar i CMYK-dokument: välj först Fil › Dokumentets färgläge › CMYK-färg @msg `{key}` must be 0..{max} `{key}` måste vara 0..{max} +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Hörnverktyget + Slide Anchor Along Path Skjut ankarpunkt längs bana + Set Corners Ange hörn + Remove Corners Ta bort hörn + Corners selected: {count} Valda hörn: {count} + Apply to Selected Anchors Tillämpa på valda ankarpunkter +@msg `radius` must be from 0 to 1000000 pt `radius` måste vara från 0 till 1000000 pt +@msg `t` must be a number from -0.98 to 0.98 `t` måste vara ett tal från -0.98 till 0.98 +@msg choose anchors where two sides meet at an angle: only those take a corner välj ankarpunkter där två sidor möts i en vinkel: bara de kan få ett hörn +@msg give `anchor`, the index of the anchor to slide ange `anchor`, indexet för ankarpunkten som ska skjutas +@msg that anchor can't slide: an end of an open path has nowhere to go den ankarpunkten kan inte skjutas: änden av en öppen bana har ingenstans att ta vägen +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Verktyg för sammanfogning av gemensam kant + Merge Shared Edge Sammanfoga gemensam kant + Keep Shared Points Together Håll ihop gemensamma punkter + Shared Edges Gemensamma kanter + Coincidence: Sammanfall: +@msg `a` and `b` must be two different paths `a` och `b` måste vara två olika banor +@msg `diameter` must be from 0.25 to 200 pt `diameter` måste vara från 0.25 till 200 pt +@msg `tolerance` must be from 0 to 3 pt `tolerance` måste vara från 0 till 3 pt +@msg `follow` must be "average", "a" or "b" `follow` måste vara "average", "a" eller "b" +@msg the paths have nothing to merge there banorna har inget att sammanfoga här + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Dela upp text + Combine Point Text Kombinera punkttext + Remove Breaks Ta bort brytningar + Split Text at Cursor Dela text vid markören + Set Alignment, Keep Position Ställ in justering, behåll position + Reset Text Transform Återställ texttransformering + Replace Missing Fonts… Ersätt saknade typsnitt… + Replace Missing Fonts Ersätt saknade typsnitt + Remove Line Breaks Ta bort radbrytningar + Remove Paragraph Breaks Ta bort styckebrytningar + Break Text into Lines Dela upp text i rader + Break Text into Paragraphs Dela upp text i stycken + Break Text into Words Dela upp text i ord + Break Text into Glyphs Dela upp text i glyfer + Reset Text Transform and Scale Återställ texttransformering och skala + Set Alignment, Keep Position: Ställ in justering, behåll position: + Break Text into: Dela upp text i: + Split at Cursor Dela vid markören + Reset Transform Återställ transformering + Shift-click: break into paragraphs Shift-klicka: dela upp i stycken + Alt-click: reset the horizontal scale too Alt-klick: återställ även den horisontella skalan +breakText Lines Rader +breakText Words Ord +breakText Glyphs Glyfer +@msg Click in type with a Type tool to split it Klicka i text med ett textverktyg för att dela den +@msg Too many type objects to change at once För många textobjekt att ändra på en gång +@msg Too many pieces to make at once För många delar att skapa på en gång +@msg Select horizontal point or area type whose text fits Markera vågrät punkt- eller områdestext vars text får plats +@msg Nothing to split at the cursor Inget att dela vid markören +@msg Select two or more one-line point type objects on one baseline to combine Markera två eller fler enradiga punkttextobjekt på samma baslinje för att kombinera dem +@msg No line breaks to remove Inga radbrytningar att ta bort +@msg No paragraph breaks to remove Inga styckebrytningar att ta bort +@msg Too many breaks to remove at once För många brytningar att ta bort på en gång +@msg Select point type Markera punkttext +@msg The point type is aligned that way already Punkttexten är redan justerad så +@msg The point type has no transform to reset Punkttexten har ingen transformering att återställa +@msg No fonts are missing Inga typsnitt saknas +@msg the replacement font is not available ersättningstypsnittet är inte tillgängligt +@msg missing `align` `align` saknas +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` måste vara lines, paragraphs, words eller glyphs, inte {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` måste vara "line" eller "paragraph", inte {other} +@msg `align` must be "left", "center" or "right", not {other} `align` måste vara "left", "center" eller "right", inte {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects Baneffekter + Reverse Direction Vänd riktning + Align to Corners and Ends Justera mot hörn och ändar + Count Antal + Max Count Högsta antal + Start Length Startlängd + Start Percent Startprocent + Start Shape Startform + End Length Slutlängd + End Percent Slutprocent + End Shape Slutform + By Count Efter antal + By Distance Efter avstånd + Both Båda + Shorter Than Length Kortare än längden + Dashes to Geometry Streck till geometri + Dashes to Geometry… Streck till geometri… + Add Anchors Lägg till ankarpunkter + Add Anchors… Lägg till ankarpunkter… + Smooth Anchors Jämna ut ankarpunkter + Smooth Anchors… Jämna ut ankarpunkter… + Simplify Anchors Förenkla ankarpunkter + Simplify Anchors… Förenkla ankarpunkter… + Split at Anchors Dela vid ankarpunkter + Split at Anchors… Dela vid ankarpunkter… + Extend Path… Förläng bana… + Close Path… Stäng bana… + Retract Handles… Dra in handtag… + Dash 1 Streck 1 + Gap 1 Mellanrum 1 + Dash 2 Streck 2 + Gap 2 Mellanrum 2 + Dash 3 Streck 3 + Gap 3 Mellanrum 3 + Dash 4 Streck 4 + Gap 4 Mellanrum 4 + Dash 5 Streck 5 + Gap 5 Mellanrum 5 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7b335bc5f..5f0dcc41b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -495,7 +495,7 @@ font-technology Variable Değişken Collect in New Layer Yeni Katmanda Topla Collect Links in a Separate Folder Bağlantıları Ayrı Bir Klasörde Toplayın Color Renk - Color (,) Renk (,) + Color (, or <) Renk (, veya <) Color Adjustments Renk Ayarlamaları Color bars Renk çubukları Color blindness – Deuteranopia-type Renk körlüğü – Döteranopi tipi @@ -1219,7 +1219,7 @@ font-technology Variable Değişken GPL palettes keep solid colours only, as RGB. GPL paletleri yalnızca düz renkleri RGB olarak tutar. GPU Performance GPU Performansı Gradient Degrade - Gradient (.) Gradyan (.) + Gradient (. or >) Gradyan (. veya >) Gradient across stroke Kontur boyunca gradyan Gradient along stroke Kontur boyunca gradyan Gradient and Mesh Resolution: Gradyan ve Mesh Çözünürlüğü: @@ -5845,3 +5845,110 @@ anchor Points: Noktalar: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` `inkLimit`, `removeWhiteOverprint` veya `overprintBlack` verin @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Baskı Mürekkebi Düzeltmeleri CMYK belgelerinde çalışır: önce Dosya › Belge Renk Modu › CMYK Rengi'ni seçin @msg `{key}` must be 0..{max} `{key}` 0..{max} olmalıdır +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Köşe Aracı + Slide Anchor Along Path Bağlantı Noktasını Yol Boyunca Kaydır + Set Corners Köşeleri Ayarla + Remove Corners Köşeleri Kaldır + Corners selected: {count} Seçili köşeler: {count} + Apply to Selected Anchors Seçili Bağlantı Noktalarına Uygula +@msg `radius` must be from 0 to 1000000 pt `radius` 0 ile 1000000 pt arasında olmalıdır +@msg `t` must be a number from -0.98 to 0.98 `t` -0.98 ile 0.98 arasında bir sayı olmalıdır +@msg choose anchors where two sides meet at an angle: only those take a corner iki kenarın açıyla birleştiği bağlantı noktalarını seçin: yalnızca onlara köşe uygulanır +@msg give `anchor`, the index of the anchor to slide kaydırılacak bağlantı noktasının dizini `anchor` değerini verin +@msg that anchor can't slide: an end of an open path has nowhere to go bu bağlantı noktası kaydırılamaz: açık yolun ucunun gidecek yeri yok +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Ortak Kenar Birleştirme Aracı + Merge Shared Edge Ortak Kenarı Birleştir + Keep Shared Points Together Ortak Noktaları Bir Arada Tut + Shared Edges Ortak Kenarlar + Coincidence: Çakışma: +@msg `a` and `b` must be two different paths `a` ve `b` iki farklı yol olmalıdır +@msg `diameter` must be from 0.25 to 200 pt `diameter` 0.25 ile 200 pt arasında olmalıdır +@msg `tolerance` must be from 0 to 3 pt `tolerance` 0 ile 3 pt arasında olmalıdır +@msg `follow` must be "average", "a" or "b" `follow` "average", "a" veya "b" olmalıdır +@msg the paths have nothing to merge there yolların burada birleştirilecek bir kısmı yok + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Metni Böl + Combine Point Text Nokta Metnini Birleştir + Remove Breaks Kesmeleri Kaldır + Split Text at Cursor Metni İmleçte Böl + Set Alignment, Keep Position Hizalamayı Ayarla, Konumu Koru + Reset Text Transform Metin Dönüşümünü Sıfırla + Replace Missing Fonts… Eksik Yazı Tiplerini Değiştir… + Replace Missing Fonts Eksik Yazı Tiplerini Değiştir + Remove Line Breaks Satır Sonlarını Kaldır + Remove Paragraph Breaks Paragraf Sonlarını Kaldır + Break Text into Lines Metni Satırlara Böl + Break Text into Paragraphs Metni Paragraflara Böl + Break Text into Words Metni Sözcüklere Böl + Break Text into Glyphs Metni Gliflere Böl + Reset Text Transform and Scale Metin Dönüşümünü ve Ölçeğini Sıfırla + Set Alignment, Keep Position: Hizalamayı Ayarla, Konumu Koru: + Break Text into: Metni Böl: + Split at Cursor İmleçte Böl + Reset Transform Dönüşümü Sıfırla + Shift-click: break into paragraphs Shift tuşuyla tıklayın: paragraflara böl + Alt-click: reset the horizontal scale too Alt tuşuyla tıklayın: yatay ölçeği de sıfırla +breakText Lines Satırlar +breakText Words Sözcükler +breakText Glyphs Glifler +@msg Click in type with a Type tool to split it Bölmek için bir Yazım Aracıyla metnin içine tıklayın +@msg Too many type objects to change at once Bir kerede değiştirilecek çok fazla metin nesnesi var +@msg Too many pieces to make at once Bir kerede oluşturulacak çok fazla parça var +@msg Select horizontal point or area type whose text fits Metni sığan yatay nokta veya alan metni seçin +@msg Nothing to split at the cursor İmleçte bölünecek bir şey yok +@msg Select two or more one-line point type objects on one baseline to combine Birleştirmek için aynı taban çizgisinde iki veya daha fazla tek satırlı nokta metni nesnesi seçin +@msg No line breaks to remove Kaldırılacak satır sonu yok +@msg No paragraph breaks to remove Kaldırılacak paragraf sonu yok +@msg Too many breaks to remove at once Bir kerede kaldırılacak çok fazla kesme var +@msg Select point type Nokta metni seçin +@msg The point type is aligned that way already Nokta metni zaten bu şekilde hizalı +@msg The point type has no transform to reset Nokta metninin sıfırlanacak dönüşümü yok +@msg No fonts are missing Eksik yazı tipi yok +@msg the replacement font is not available yedek yazı tipi kullanılamıyor +@msg missing `align` `align` eksik +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by`, lines, paragraphs, words veya glyphs olmalı, {other} değil +@msg `kind` must be "line" or "paragraph", not {other} `kind`, "line" veya "paragraph" olmalı, {other} değil +@msg `align` must be "left", "center" or "right", not {other} `align`, "left", "center" veya "right" olmalı, {other} değil + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects Yol Efektleri + Reverse Direction Yönü Ters Çevir + Align to Corners and Ends Köşelere ve Uçlara Hizala + Count Sayı + Max Count En Fazla Sayı + Start Length Başlangıç Uzunluğu + Start Percent Başlangıç Yüzdesi + Start Shape Başlangıç Şekli + End Length Bitiş Uzunluğu + End Percent Bitiş Yüzdesi + End Shape Bitiş Şekli + By Count Sayıya Göre + By Distance Mesafeye Göre + Both Her İkisi + Shorter Than Length Uzunluktan Kısa Olanlar + Dashes to Geometry Çizgileri Geometriye Dönüştür + Dashes to Geometry… Çizgileri Geometriye Dönüştür… + Add Anchors Bağlantı Noktaları Ekle + Add Anchors… Bağlantı Noktaları Ekle… + Smooth Anchors Bağlantı Noktalarını Yumuşat + Smooth Anchors… Bağlantı Noktalarını Yumuşat… + Simplify Anchors Bağlantı Noktalarını Basitleştir + Simplify Anchors… Bağlantı Noktalarını Basitleştir… + Split at Anchors Bağlantı Noktalarından Böl + Split at Anchors… Bağlantı Noktalarından Böl… + Extend Path… Yolu Uzat… + Close Path… Yolu Kapat… + Retract Handles… Kolları Geri Çek… + Dash 1 Çizgi 1 + Gap 1 Boşluk 1 + Dash 2 Çizgi 2 + Gap 2 Boşluk 2 + Dash 3 Çizgi 3 + Gap 3 Boşluk 3 + Dash 4 Çizgi 4 + Gap 4 Boşluk 4 + Dash 5 Çizgi 5 + Gap 5 Boşluk 5 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 13a0d1f41..1594ee0eb 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -507,7 +507,7 @@ font-technology Variable Варіативні Collect in New Layer Зібрати у новий шар Collect Links in a Separate Folder Зібрати зв’язки в окрему папку Color Колір - Color (,) Колір (,) + Color (, or <) Колір (, або <) Color Adjustments Налаштування кольору Color bars Кольорові смуги Color blindness – Deuteranopia-type Дальтонізм - дейтеранопія @@ -1281,7 +1281,7 @@ file-filter Templates Шаблони GPL palettes keep solid colours only, as RGB. Палітри GPL зберігають лише суцільні кольори RGB. GPU Performance Продуктивність GPU Gradient Градієнт - Gradient (.) Градієнт (.) + Gradient (. or >) Градієнт (. або >) Gradient across stroke Градієнт упоперек обведення Gradient along stroke Градієнт вздовж обведення Gradient and Mesh Resolution: Роздільна здатність градієнтів і сіток: @@ -5486,3 +5486,111 @@ anchor Points: Точки: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` задайте `inkLimit`, `removeWhiteOverprint` або `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Виправлення друкарських фарб працюють у документах CMYK: спершу виберіть Файл › Колірний режим документа › Колір CMYK @msg `{key}` must be 0..{max} `{key}` має бути 0..{max} +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Інструмент «Кути» + Slide Anchor Along Path Зсунути опорну точку вздовж контуру + Set Corners Задати кути + Remove Corners Забрати кути + Corners selected: {count} Вибрано кутів: {count} + Apply to Selected Anchors Застосувати до вибраних опорних точок +@msg `radius` must be from 0 to 1000000 pt `radius` має бути від 0 до 1000000 pt +@msg `t` must be a number from -0.98 to 0.98 `t` має бути числом від -0.98 до 0.98 +@msg choose anchors where two sides meet at an angle: only those take a corner виберіть опорні точки, де дві сторони сходяться під кутом: лише вони приймають кут +@msg give `anchor`, the index of the anchor to slide вкажіть `anchor`, індекс опорної точки для зсуву +@msg that anchor can't slide: an end of an open path has nowhere to go цю опорну точку не можна зсунути: кінцю відкритого контуру нікуди рухатися +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Інструмент «Об’єднання спільного краю» + Merge Shared Edge Об’єднати спільний край + Keep Shared Points Together Тримати спільні точки разом + Shared Edges Спільні краї + Coincidence: Збіг: +@msg `a` and `b` must be two different paths `a` і `b` мають бути двома різними контурами +@msg `diameter` must be from 0.25 to 200 pt `diameter` має бути від 0.25 до 200 pt +@msg `tolerance` must be from 0 to 3 pt `tolerance` має бути від 0 до 3 pt +@msg `follow` must be "average", "a" or "b" `follow` має бути "average", "a" або "b" +@msg the paths have nothing to merge there тут у контурів немає чого об’єднувати + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Розбити текст + Combine Point Text Об’єднати текст із точки + Remove Breaks Видалити розриви + Split Text at Cursor Розбити текст біля курсора + Set Alignment, Keep Position Задати вирівнювання, зберігши положення + Reset Text Transform Скинути трансформацію тексту + Replace Missing Fonts… Замінити відсутні шрифти… + Replace Missing Fonts Замінити відсутні шрифти + Remove Line Breaks Видалити розриви рядків + Remove Paragraph Breaks Видалити розриви абзаців + Break Text into Lines Розбити текст на рядки + Break Text into Paragraphs Розбити текст на абзаци + Break Text into Words Розбити текст на слова + Break Text into Glyphs Розбити текст на гліфи + Reset Text Transform and Scale Скинути трансформацію та масштаб тексту + Set Alignment, Keep Position: Вирівнювання зі збереженням положення: + Break Text into: Розбити текст на: + Split at Cursor Розбити біля курсора + Reset Transform Скинути трансформацію + Shift-click: break into paragraphs Shift+клацання: розбити на абзаци + Alt-click: reset the horizontal scale too Alt+клацання: скинути й горизонтальний масштаб +breakText Lines Рядки +breakText Words Слова +breakText Glyphs Гліфи +@msg Click in type with a Type tool to split it Клацніть у тексті інструментом «Текст», щоб розбити його +@msg Too many type objects to change at once Забагато текстових об’єктів, щоб змінити їх за раз +@msg Too many pieces to make at once Забагато частин, щоб створити їх за раз +@msg Select horizontal point or area type whose text fits Виділіть горизонтальний текст із точки або в області, текст якого вміщується +@msg Nothing to split at the cursor Біля курсора нічого розбивати +@msg Select two or more one-line point type objects on one baseline to combine Виділіть два або більше однорядкових об’єктів тексту з точки на одній базовій лінії, щоб об’єднати їх +@msg No line breaks to remove Немає розривів рядків для видалення +@msg No paragraph breaks to remove Немає розривів абзаців для видалення +@msg Too many breaks to remove at once Забагато розривів, щоб видалити їх за раз +@msg Select point type Виділіть текст із точки +@msg The point type is aligned that way already Текст із точки вже так вирівняно +@msg The point type has no transform to reset Текст із точки не має трансформації для скидання +@msg No fonts are missing Відсутніх шрифтів немає +@msg the replacement font is not available шрифт для заміни недоступний +@msg missing `align` не вказано `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` має бути lines, paragraphs, words або glyphs, а не {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` має бути "line" або "paragraph", а не {other} +@msg `align` must be "left", "center" or "right", not {other} `align` має бути "left", "center" або "right", а не {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects Ефекти контуру + Reverse Direction Змінити напрям + Align to Corners and Ends Вирівняти за кутами й кінцями + Count Кількість + Max Count Найбільша кількість + Start Length Довжина на початку + Start Percent Відсотки на початку + Start Shape Форма на початку + End Length Довжина в кінці + End Percent Відсотки в кінці + End Shape Форма в кінці + By Count За кількістю + By Distance За відстанню + Both Обидва + Shorter Than Length Коротші за довжину + Anchors Опорні точки + Dashes to Geometry Штрихи в геометрію + Dashes to Geometry… Штрихи в геометрію… + Add Anchors Додати опорні точки + Add Anchors… Додати опорні точки… + Smooth Anchors Згладити опорні точки + Smooth Anchors… Згладити опорні точки… + Simplify Anchors Спростити опорні точки + Simplify Anchors… Спростити опорні точки… + Split at Anchors Розрізати в опорних точках + Split at Anchors… Розрізати в опорних точках… + Extend Path… Подовжити контур… + Close Path… Замкнути контур… + Retract Handles… Прибрати маркери… + Dash 1 Штрих 1 + Gap 1 Проміжок 1 + Dash 2 Штрих 2 + Gap 2 Проміжок 2 + Dash 3 Штрих 3 + Gap 3 Проміжок 3 + Dash 4 Штрих 4 + Gap 4 Проміжок 4 + Dash 5 Штрих 5 + Gap 5 Проміжок 5 diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 169e1e08c..89841aced 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -595,7 +595,7 @@ font-technology Variable Biến thiên Collect in New Layer Thu thập vào lớp mới Collect Links in a Separate Folder Thu thập liên kết vào thư mục riêng Color Màu - Color (,) Màu (,) + Color (, or <) Màu (, hoặc <) Color Adjustments Điều chỉnh màu sắc Color bars Thanh màu Color blindness – Deuteranopia-type Mù màu – loại Deuteranopia @@ -1495,7 +1495,7 @@ file-filter Templates Mẫu GPU Performance Hiệu suất GPU Grab bag Tổng hợp Gradient Chuyển sắc - Gradient (.) Chuyển sắc (.) + Gradient (. or >) Chuyển sắc (. hoặc >) Gradient across stroke Chuyển sắc ngang qua nét Gradient along stroke Chuyển sắc dọc theo nét Gradient and Mesh Resolution: Độ phân giải chuyển sắc và lưới: @@ -5488,3 +5488,111 @@ anchor Points: Điểm: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` hãy cho `inkLimit`, `removeWhiteOverprint` hoặc `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first Sửa mực in hoạt động trên tài liệu CMYK: trước hết chọn Tệp › Chế độ màu tài liệu › Màu CMYK @msg `{key}` must be 0..{max} `{key}` phải là 0..{max} +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool Công cụ Góc + Slide Anchor Along Path Trượt điểm neo dọc đường dẫn + Set Corners Đặt góc + Remove Corners Gỡ góc + Corners selected: {count} Góc đã chọn: {count} + Apply to Selected Anchors Áp dụng cho các điểm neo đã chọn +@msg `radius` must be from 0 to 1000000 pt `radius` phải từ 0 đến 1000000 pt +@msg `t` must be a number from -0.98 to 0.98 `t` phải là số từ -0.98 đến 0.98 +@msg choose anchors where two sides meet at an angle: only those take a corner hãy chọn các điểm neo nơi hai cạnh gặp nhau thành góc: chỉ những điểm đó mới nhận góc +@msg give `anchor`, the index of the anchor to slide hãy cho biết `anchor`, chỉ số của điểm neo cần trượt +@msg that anchor can't slide: an end of an open path has nowhere to go không thể trượt điểm neo đó: đầu của đường dẫn mở không có chỗ để đi +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool Công cụ Hợp nhất cạnh chung + Merge Shared Edge Hợp nhất cạnh chung + Keep Shared Points Together Giữ các điểm chung đi cùng nhau + Shared Edges Cạnh chung + Coincidence: Độ trùng khớp: +@msg `a` and `b` must be two different paths `a` và `b` phải là hai đường dẫn khác nhau +@msg `diameter` must be from 0.25 to 200 pt `diameter` phải từ 0.25 đến 200 pt +@msg `tolerance` must be from 0 to 3 pt `tolerance` phải từ 0 đến 3 pt +@msg `follow` must be "average", "a" or "b" `follow` phải là "average", "a" hoặc "b" +@msg the paths have nothing to merge there các đường dẫn không có gì để hợp nhất ở đây + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text Tách văn bản + Combine Point Text Kết hợp chữ điểm + Remove Breaks Xóa ngắt + Split Text at Cursor Tách văn bản tại con trỏ + Set Alignment, Keep Position Đặt căn lề, giữ vị trí + Reset Text Transform Đặt lại biến đổi văn bản + Replace Missing Fonts… Thay thế phông chữ thiếu… + Replace Missing Fonts Thay thế phông chữ thiếu + Remove Line Breaks Xóa xuống dòng + Remove Paragraph Breaks Xóa ngắt đoạn + Break Text into Lines Tách văn bản thành dòng + Break Text into Paragraphs Tách văn bản thành đoạn + Break Text into Words Tách văn bản thành từ + Break Text into Glyphs Tách văn bản thành glyph + Reset Text Transform and Scale Đặt lại biến đổi và tỷ lệ văn bản + Set Alignment, Keep Position: Đặt căn lề, giữ vị trí: + Break Text into: Tách văn bản thành: + Split at Cursor Tách tại con trỏ + Reset Transform Đặt lại biến đổi + Shift-click: break into paragraphs Shift-bấm: tách thành đoạn + Alt-click: reset the horizontal scale too Alt-bấm: đặt lại cả tỷ lệ ngang +breakText Lines Dòng +breakText Words Từ +breakText Glyphs Glyph +@msg Click in type with a Type tool to split it Nhấp vào văn bản bằng công cụ Chữ để tách văn bản +@msg Too many type objects to change at once Quá nhiều đối tượng chữ để thay đổi cùng lúc +@msg Too many pieces to make at once Quá nhiều phần để tạo cùng lúc +@msg Select horizontal point or area type whose text fits Chọn chữ điểm hoặc chữ vùng nằm ngang có văn bản vừa khung +@msg Nothing to split at the cursor Không có gì để tách tại con trỏ +@msg Select two or more one-line point type objects on one baseline to combine Chọn hai đối tượng chữ điểm một dòng trở lên trên cùng một đường cơ sở để kết hợp +@msg No line breaks to remove Không có xuống dòng nào để xóa +@msg No paragraph breaks to remove Không có ngắt đoạn nào để xóa +@msg Too many breaks to remove at once Quá nhiều ngắt để xóa cùng lúc +@msg Select point type Chọn chữ điểm +@msg The point type is aligned that way already Chữ điểm đã được căn như vậy +@msg The point type has no transform to reset Chữ điểm không có biến đổi nào để đặt lại +@msg No fonts are missing Không thiếu phông chữ nào +@msg the replacement font is not available phông chữ thay thế không có sẵn +@msg missing `align` thiếu `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` phải là lines, paragraphs, words hoặc glyphs, không phải {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` phải là "line" hoặc "paragraph", không phải {other} +@msg `align` must be "left", "center" or "right", not {other} `align` phải là "left", "center" hoặc "right", không phải {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects Hiệu ứng đường dẫn + Reverse Direction Đảo ngược hướng + Align to Corners and Ends Căn theo góc và đầu mút + Count Số lượng + Max Count Số lượng tối đa + Start Length Độ dài đầu + Start Percent Phần trăm đầu + Start Shape Hình dạng đầu + End Length Độ dài cuối + End Percent Phần trăm cuối + End Shape Hình dạng cuối + By Count Theo số lượng + By Distance Theo khoảng cách + Both Cả hai + Shorter Than Length Ngắn hơn độ dài + Anchors Điểm neo + Dashes to Geometry Nét đứt thành hình học + Dashes to Geometry… Nét đứt thành hình học… + Add Anchors Thêm các điểm neo + Add Anchors… Thêm các điểm neo… + Smooth Anchors Làm mượt điểm neo + Smooth Anchors… Làm mượt điểm neo… + Simplify Anchors Đơn giản hóa điểm neo + Simplify Anchors… Đơn giản hóa điểm neo… + Split at Anchors Tách tại điểm neo + Split at Anchors… Tách tại điểm neo… + Extend Path… Kéo dài đường dẫn… + Close Path… Đóng đường dẫn… + Retract Handles… Thu tay nắm… + Dash 1 Nét 1 + Gap 1 Khoảng hở 1 + Dash 2 Nét 2 + Gap 2 Khoảng hở 2 + Dash 3 Nét 3 + Gap 3 Khoảng hở 3 + Dash 4 Nét 4 + Gap 4 Khoảng hở 4 + Dash 5 Nét 5 + Gap 5 Khoảng hở 5 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index cb4cb60f1..b5d689270 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -459,7 +459,7 @@ Collect in New Layer 收集到新图层 Collect Links in a Separate Folder 将链接收集到单独的文件夹 Color 颜色 - Color (,) 颜色 (,) + Color (, or <) 颜色 (, 或 <) Color Adjustments 色彩调整 Color bars 色彩条 Color blindness – Deuteranopia-type 色盲 – 绿色盲型 @@ -1225,7 +1225,7 @@ file-filter Templates 模板 GPL palettes keep solid colours only, as RGB. GPL 调板仅保留为 RGB 的纯色。 GPU Performance GPU 性能 Gradient 渐变 - Gradient (.) 渐变 (.) + Gradient (. or >) 渐变 (. 或 >) Gradient across stroke 横跨描边的渐变 Gradient along stroke 沿描边的渐变 Gradient and Mesh Resolution: 渐变和网格分辨率: @@ -5729,3 +5729,110 @@ anchor Points: 点: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` 请指定 `inkLimit`、`removeWhiteOverprint` 或 `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first 印刷油墨修正适用于 CMYK 文档:请先选择 文件 › 文档颜色模式 › CMYK 颜色 @msg `{key}` must be 0..{max} `{key}` 必须为 0..{max} +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool 转角工具 + Slide Anchor Along Path 沿路径滑动锚点 + Set Corners 设置转角 + Remove Corners 移除转角 + Corners selected: {count} 已选转角:{count} + Apply to Selected Anchors 应用到所选锚点 +@msg `radius` must be from 0 to 1000000 pt `radius` 必须为 0 到 1000000 pt +@msg `t` must be a number from -0.98 to 0.98 `t` 必须是 -0.98 到 0.98 之间的数字 +@msg choose anchors where two sides meet at an angle: only those take a corner 请选择两条边成角相交的锚点:只有这些锚点才能设置转角 +@msg give `anchor`, the index of the anchor to slide 请提供 `anchor`,即要滑动的锚点索引 +@msg that anchor can't slide: an end of an open path has nowhere to go 该锚点无法滑动:开放路径的端点无处可去 +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool 共享边合并工具 + Merge Shared Edge 合并共享边 + Keep Shared Points Together 保持共享点在一起 + Shared Edges 共享边 + Coincidence: 重合容差: +@msg `a` and `b` must be two different paths `a` 和 `b` 必须是两条不同的路径 +@msg `diameter` must be from 0.25 to 200 pt `diameter` 必须为 0.25 到 200 pt +@msg `tolerance` must be from 0 to 3 pt `tolerance` 必须为 0 到 3 pt +@msg `follow` must be "average", "a" or "b" `follow` 必须是 "average"、"a" 或 "b" +@msg the paths have nothing to merge there 这里没有可合并的路径部分 + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text 拆分文本 + Combine Point Text 合并点文字 + Remove Breaks 删除换行 + Split Text at Cursor 在光标处拆分文本 + Set Alignment, Keep Position 设置对齐并保持位置 + Reset Text Transform 重置文本变换 + Replace Missing Fonts… 替换缺失的字体… + Replace Missing Fonts 替换缺失的字体 + Remove Line Breaks 删除强制换行 + Remove Paragraph Breaks 删除段落换行 + Break Text into Lines 将文本拆分为行 + Break Text into Paragraphs 将文本拆分为段落 + Break Text into Words 将文本拆分为单词 + Break Text into Glyphs 将文本拆分为字形 + Reset Text Transform and Scale 重置文本变换和缩放 + Set Alignment, Keep Position: 设置对齐并保持位置: + Break Text into: 将文本拆分为: + Split at Cursor 在光标处拆分 + Reset Transform 重置变换 + Shift-click: break into paragraphs Shift+单击:拆分为段落 + Alt-click: reset the horizontal scale too Alt+点击:同时重置水平缩放 +breakText Lines 行 +breakText Words 单词 +breakText Glyphs 字形 +@msg Click in type with a Type tool to split it 请用文字工具在文字中单击以拆分 +@msg Too many type objects to change at once 一次要更改的文字对象过多 +@msg Too many pieces to make at once 一次要创建的部分过多 +@msg Select horizontal point or area type whose text fits 请选择文本能完全容纳的横排点文字或区域文字 +@msg Nothing to split at the cursor 光标处没有可拆分的内容 +@msg Select two or more one-line point type objects on one baseline to combine 请选择同一基线上两个或更多单行点文字对象以合并 +@msg No line breaks to remove 没有要删除的强制换行 +@msg No paragraph breaks to remove 没有要删除的段落换行 +@msg Too many breaks to remove at once 一次要删除的换行过多 +@msg Select point type 请选择点文字 +@msg The point type is aligned that way already 点文字已经是这种对齐方式 +@msg The point type has no transform to reset 点文字没有可重置的变换 +@msg No fonts are missing 没有缺失的字体 +@msg the replacement font is not available 替换字体不可用 +@msg missing `align` 缺少 `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` 必须是 lines、paragraphs、words 或 glyphs,而不是 {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` 必须是 "line" 或 "paragraph",而不是 {other} +@msg `align` must be "left", "center" or "right", not {other} `align` 必须是 "left"、"center" 或 "right",而不是 {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects 路径效果 + Reverse Direction 反转方向 + Align to Corners and Ends 对齐转角和端点 + Count 数量 + Max Count 最大数量 + Start Length 起点长度 + Start Percent 起点百分比 + Start Shape 起点形状 + End Length 终点长度 + End Percent 终点百分比 + End Shape 终点形状 + By Count 按数量 + By Distance 按距离 + Both 两者 + Shorter Than Length 短于长度 + Dashes to Geometry 虚线转为几何图形 + Dashes to Geometry… 虚线转为几何图形… + Add Anchors 添加锚点 + Add Anchors… 添加锚点… + Smooth Anchors 平滑锚点 + Smooth Anchors… 平滑锚点… + Simplify Anchors 简化锚点 + Simplify Anchors… 简化锚点… + Split at Anchors 在锚点处分割 + Split at Anchors… 在锚点处分割… + Extend Path… 延长路径… + Close Path… 闭合路径… + Retract Handles… 收回手柄… + Dash 1 虚线 1 + Gap 1 间隔 1 + Dash 2 虚线 2 + Gap 2 间隔 2 + Dash 3 虚线 3 + Gap 3 间隔 3 + Dash 4 虚线 4 + Gap 4 间隔 4 + Dash 5 虚线 5 + Gap 5 间隔 5 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 73d12d526..1d4590686 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -475,7 +475,7 @@ font-technology Variable 可變 Collect in New Layer 收集至新圖層 Collect Links in a Separate Folder 將連結收集到獨立的資料夾 Color 顏色 - Color (,) 顏色 (,) + Color (, or <) 顏色 (, 或 <) Color Adjustments 色彩調整 Color bars 色彩條 Color blindness – Deuteranopia-type 色盲 – 綠色盲型 @@ -1251,7 +1251,7 @@ file-filter Templates 範本 GPL palettes keep solid colours only, as RGB. GPL 調色盤僅保留純色,並以 RGB 儲存。 GPU Performance GPU 效能 Gradient 漸層 - Gradient (.) 漸層 (.) + Gradient (. or >) 漸層 (. 或 >) Gradient across stroke 橫跨筆畫的漸層 Gradient along stroke 沿筆畫的漸層 Gradient and Mesh Resolution: 漸層與網格解析度: @@ -5525,3 +5525,111 @@ anchor Points: 點: @msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` 請指定 `inkLimit`、`removeWhiteOverprint` 或 `overprintBlack` @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first 印刷油墨修正適用於 CMYK 文件:請先選擇 檔案 › 文件色彩模式 › CMYK 色彩 @msg `{key}` must be 0..{max} `{key}` 必須為 0..{max} +# Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path. + Corner Tool 轉角工具 + Slide Anchor Along Path 沿路徑滑動錨點 + Set Corners 設定轉角 + Remove Corners 移除轉角 + Corners selected: {count} 已選取轉角:{count} + Apply to Selected Anchors 套用至所選錨點 +@msg `radius` must be from 0 to 1000000 pt `radius` 必須為 0 到 1000000 pt +@msg `t` must be a number from -0.98 to 0.98 `t` 必須是 -0.98 到 0.98 之間的數字 +@msg choose anchors where two sides meet at an angle: only those take a corner 請選取兩邊成角相交的錨點:只有這些錨點才能設定轉角 +@msg give `anchor`, the index of the anchor to slide 請提供 `anchor`,即要滑動的錨點索引 +@msg that anchor can't slide: an end of an open path has nowhere to go 該錨點無法滑動:開放路徑的端點無處可去 +# Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together. + Shared Edge Merge Tool 共用邊合併工具 + Merge Shared Edge 合併共用邊 + Keep Shared Points Together 保持共用點在一起 + Shared Edges 共用邊 + Coincidence: 重合容差: +@msg `a` and `b` must be two different paths `a` 和 `b` 必須是兩條不同的路徑 +@msg `diameter` must be from 0.25 to 200 pt `diameter` 必須為 0.25 到 200 pt +@msg `tolerance` must be from 0 to 3 pt `tolerance` 必須為 0 到 3 pt +@msg `follow` must be "average", "a" or "b" `follow` 必須是 "average"、"a" 或 "b" +@msg the paths have nothing to merge there 這裡沒有可合併的路徑部分 + +# Artwork Cleanup add-on: its text tools (Object › Plug-ins › Artwork Cleanup and the panel's Text row) and their messages. + Break Text 分割文字 + Combine Point Text 合併點文字 + Remove Breaks 移除換行 + Split Text at Cursor 在游標處分割文字 + Set Alignment, Keep Position 設定對齊並保持位置 + Reset Text Transform 重設文字變形 + Replace Missing Fonts… 取代遺失的字體… + Replace Missing Fonts 取代遺失的字體 + Remove Line Breaks 移除強制換行 + Remove Paragraph Breaks 移除段落換行 + Break Text into Lines 將文字分割為行 + Break Text into Paragraphs 將文字分割為段落 + Break Text into Words 將文字分割為單字 + Break Text into Glyphs 將文字分割為字符 + Reset Text Transform and Scale 重設文字變形與縮放 + Set Alignment, Keep Position: 設定對齊並保持位置: + Break Text into: 將文字分割為: + Split at Cursor 在游標處分割 + Reset Transform 重設變形 + Shift-click: break into paragraphs Shift-按一下:分割為段落 + Alt-click: reset the horizontal scale too Alt+點擊:一併重設水平縮放 +breakText Lines 行 +breakText Words 單字 +breakText Glyphs 字符 +@msg Click in type with a Type tool to split it 請用文字工具在文字中按一下以分割 +@msg Too many type objects to change at once 一次要變更的文字物件過多 +@msg Too many pieces to make at once 一次要建立的部分過多 +@msg Select horizontal point or area type whose text fits 請選取文字能完整容納的橫排點文字或區域文字 +@msg Nothing to split at the cursor 游標處沒有可分割的內容 +@msg Select two or more one-line point type objects on one baseline to combine 請選取同一基線上兩個或更多單行點文字物件以合併 +@msg No line breaks to remove 沒有要移除的強制換行 +@msg No paragraph breaks to remove 沒有要移除的段落換行 +@msg Too many breaks to remove at once 一次要移除的換行過多 +@msg Select point type 請選取點文字 +@msg The point type is aligned that way already 點文字已經是這種對齊方式 +@msg The point type has no transform to reset 點文字沒有可重設的變形 +@msg No fonts are missing 沒有遺失的字體 +@msg the replacement font is not available 替代字體無法使用 +@msg missing `align` 未提供 `align` +@msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` 必須是 lines、paragraphs、words 或 glyphs,而不是 {other} +@msg `kind` must be "line" or "paragraph", not {other} `kind` 必須是 "line" 或 "paragraph",而不是 {other} +@msg `align` must be "left", "center" or "right", not {other} `align` 必須是 "left"、"center" 或 "right",而不是 {other} + +# Path Precision Tools add-on: Path Effects (Effect › Path Effects). + Path Effects 路徑效果 + Reverse Direction 反轉方向 + Align to Corners and Ends 對齊轉角和端點 + Count 數量 + Max Count 最大數量 + Start Length 起點長度 + Start Percent 起點百分比 + Start Shape 起點形狀 + End Length 終點長度 + End Percent 終點百分比 + End Shape 終點形狀 + By Count 依數量 + By Distance 依距離 + Both 兩者 + Shorter Than Length 短於長度 + Anchors 錨點 + Dashes to Geometry 虛線轉為幾何圖形 + Dashes to Geometry… 虛線轉為幾何圖形… + Add Anchors 增加錨點 + Add Anchors… 增加錨點… + Smooth Anchors 平滑錨點 + Smooth Anchors… 平滑錨點… + Simplify Anchors 簡化錨點 + Simplify Anchors… 簡化錨點… + Split at Anchors 在錨點處分割 + Split at Anchors… 在錨點處分割… + Extend Path… 延長路徑… + Close Path… 封閉路徑… + Retract Handles… 收回控制把手… + Dash 1 虛線 1 + Gap 1 間隙 1 + Dash 2 虛線 2 + Gap 2 間隙 2 + Dash 3 虛線 3 + Gap 3 間隙 3 + Dash 4 虛線 4 + Gap 4 間隙 4 + Dash 5 虛線 5 + Gap 5 間隙 5 diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 03d08be1d..2cccc6c72 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -146,14 +146,28 @@ fn catalogue() -> Vec { if TOGGLES.contains(&e.key.as_str()) { b = b.toggle(); } - match shortcut_editor::default_of(&e.key).and_then(vc_binding) { - Some(d) => b.with_default(d), - None => b, + if let Some(d) = shortcut_editor::default_of(&e.key).and_then(vc_binding) { + b = b.with_default(d); } + for alias in default_aliases(&e.key) { + if let Some(d) = vc_binding(alias) { + b = b.with_default(d); + } + } + b }) .collect() } +/// Additional defaults for commands whose primary shortcut remains in their command spec. +fn default_aliases(key: &str) -> &'static [&'static str] { + match key { + "paint.lastColor" => &["Shift+,"], + "paint.lastGradient" => &["Shift+."], + _ => &[], + } +} + /// On the web, the browser's operating system once egui reports it ([`note_os`]). static WEB_PLATFORM: RwLock> = RwLock::new(None); @@ -398,11 +412,35 @@ mod tests { let unbound: Vec<(&str, &str)> = shortcut_editor::entries() .iter() .filter_map(|e| shortcut_editor::default_of(&e.key).map(|d| (e.key.as_str(), d))) - .filter(|(key, d)| vc_binding(d).map(|b| vec![b]) != Some(template().default_bindings(&command_of(key)))) + .filter(|(key, d)| { + let mut expected: Vec = vc_binding(d).into_iter().collect(); + expected.extend(default_aliases(key).iter().filter_map(|alias| vc_binding(alias))); + expected != template().default_bindings(&command_of(key)) + }) .collect(); assert_eq!(unbound, vec![], "every default text becomes the entry's binding"); } + #[test] + fn paint_defaults_have_two_bindings_and_reset_restores_both() { + for (key, plain, shifted) in [("paint.lastColor", ",", "Shift+,"), ("paint.lastGradient", ".", "Shift+.")] { + let command = command_of(key); + let defaults = template().default_bindings(&command); + assert_eq!(defaults, vec![vc_binding(plain).unwrap(), vc_binding(shifted).unwrap()], "{key}"); + assert_eq!( + shortcut_editor::entry(key).map(|e| e.label.as_str()), + Some(if key == "paint.lastColor" { "Apply Last Color" } else { "Apply Last Gradient" }) + ); + assert_eq!(effective_with(template(), key).as_deref(), Some(plain)); + let mut overrides = Overrides::new(); + set_override(&mut overrides, key, "Shift+;"); + assert_eq!(keymap_with(&overrides).bindings(&command), vec![vc_binding("Shift+;").unwrap()]); + shortcut_editor::reset_one(&mut overrides, key); + assert_eq!(keymap_with(&overrides).bindings(&command), defaults); + } + assert!(template().conflicts().is_empty()); + } + /// The chords the operating system takes are refused, and no default uses one. #[test] fn system_shortcuts_are_refused() { diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 339682c94..772d5a8e1 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -40,14 +40,17 @@ pub fn check_params(session: &vectorcraft_engine::Session, id: &str, params: &Va /// handles its own way (a Window menu panel item that toggles, …) → `None` to leave it to the /// engine. pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { - // The Window menu's panel items toggle like its other checked items (#2361); the panel's - // other ways in (shortcuts, Show Properties Panel, a tool's double-click) only show it. + // Window menu items and the named panel function keys toggle against the checked state; + // other commands that open panels show them. if id == "window.panel" && p.get("toggle").and_then(Value::as_bool) == Some(true) && checked(app, id, p) == Some(true) { let panel = p.get("panel").and_then(Value::as_str).and_then(normalize_panel).unwrap_or_default(); if app.ui.open_panel.as_deref() == Some(panel) { app.ui.open_panel = None; return Some(Ok(json!({"open": null}))); } + if p.get("toggleGroup").and_then(Value::as_bool) == Some(true) { + return Some(crate::panel_docking::hide_group(app, panel)); + } return Some(app.run("window.panel.close", json!({"panel": panel}))); } if let Some(result) = crate::panel_docking::command(app, id, p) { @@ -524,6 +527,8 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err(format!("unknown tool `{t}`")), None => Err("missing `tool`".into()), }, + crate::addons::pathtools::corner::COMMAND => crate::addons::pathtools::corner::open(app), + crate::addons::pathtools::shared_edge::COMMAND => crate::addons::pathtools::shared_edge::open(app), "tool.setOption" => app.session.set_tool_option_cmd(p), "effect.dialog" => crate::dialogs::open_effect_dialog(app, p), "ui.recolorDialog" => crate::dialogs::recolor::open(app, p), diff --git a/crates/ui-egui/src/menus/submenus.rs b/crates/ui-egui/src/menus/submenus.rs index 9cb1a2aa6..a86b66c82 100644 --- a/crates/ui-egui/src/menus/submenus.rs +++ b/crates/ui-egui/src/menus/submenus.rs @@ -139,6 +139,7 @@ pub(super) fn effect_menu() -> Vec { "Crop Marks", "Distort & Transform", "Path", + "Path Effects", "Pathfinder", "Rasterize…", "Stylize", diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 7a23026d2..b0ee269ad 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -677,7 +677,11 @@ fn lock_and_hide_menus_are_greyed_out_in_isolation_mode() { app.run("file.new", json!({})).unwrap(); let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone(); let b = app.run("shape.rectangle", json!({"x": 80, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("select.none", json!({})).unwrap(); + let lock_unselected = |app: &VectorcraftApp| crate::menus::enabled(app, "object.lock.unselected"); + assert!(!lock_unselected(&app), "nothing selected"); app.run("select.set", json!({"ids": [a, b.clone()]})).unwrap(); + assert!(lock_unselected(&app), "selection present"); let g = app.run("object.group", json!({})).unwrap()["id"].clone(); let lock_and_hide: Vec = menu_entries(&app) .into_iter() @@ -712,6 +716,7 @@ fn lock_and_hide_menus_are_greyed_out_in_isolation_mode() { app.run("object.isolate", json!({"id": g.clone()})).unwrap(); app.run("select.set", json!({"ids": [b]})).unwrap(); assert_eq!(shown(&app), [false, false, false, false, false, false, false, false, false, true], "in isolation mode"); + assert!(lock_unselected(&app), "shortcut command remains enabled in isolation"); app.run("object.exitIsolation", json!({})).unwrap(); app.run("select.set", json!({"ids": [g]})).unwrap(); assert_eq!(shown(&app), [true; 10], "after Exit Isolation Mode"); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 5a99c4ca6..bdc096616 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1,5 +1,17 @@ /// UI-level commands: (id, label, shortcut, params doc). pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ + ( + crate::addons::pathtools::corner::COMMAND, + "Corner Tool", + "", + "{} choose the Corner Tool and show the Anchor Inspector panel with its radius and kind", + ), + ( + crate::addons::pathtools::shared_edge::COMMAND, + "Shared Edge Merge Tool", + "", + "{} choose the Shared Edge Merge Tool and show the Anchor Inspector panel with its brush, coincidence and Keep Shared Points Together", + ), ( "app.language", "Interface Language", diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index dfedad31d..d19b7be2c 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -337,6 +337,9 @@ fn show_panel(app: &mut VectorcraftApp, params: &Value) -> Result if !layout.contains(&panel.to_string()) && !app.ui.docking_hidden.contains_key(panel) && crate::panels::library_panel::parse(panel).is_some() { return crate::panels::library_panel::float(app, panel); } + if app.ui.docking_hidden.contains_key(panel) { + return restore_hidden_group(app, panel); + } let value = shared_command(app, "window.panel.activate", &json!({"panel": panel}))?; let layout = layout_of(&app.ui); Ok(match floating_window(&layout, panel) { @@ -345,6 +348,37 @@ fn show_panel(app: &mut VectorcraftApp, params: &Value) -> Result }) } +/// Hides the group holding an active panel and records each tab's return location. +pub(crate) fn hide_group(app: &mut VectorcraftApp, panel: &str) -> Result { + let mut txn = Transaction::begin(app)?; + let panel = panel.to_string(); + let closed = txn.apply(Action::CloseGroup { panel })?; + let group = closed.clone(); + let result = txn.commit(app, closed, None); + if !group.is_empty() { + app.ui.docking_hidden_groups.push(group); + } + Ok(result) +} + +/// Reopens the tabs hidden together with `panel` and brings it to the front. +fn restore_hidden_group(app: &mut VectorcraftApp, panel: &str) -> Result { + let mut txn = Transaction::begin(app)?; + let members = + app.ui.docking_hidden_groups.iter().find(|group| group.iter().any(|id| id == panel)).cloned().unwrap_or_else(|| vec![panel.to_string()]); + for member in members { + if !txn.workspace.hidden.iter().any(|(id, _)| id == &member) { + continue; + } + let fallback = first_dock_panel(&txn.workspace.layout, &member); + txn.workspace.set_visible(member, true, fallback, model_permissions).map_err(|e| e.to_string())?; + } + txn.apply(Action::Activate { panel: panel.to_string() })?; + let result = txn.commit(app, Vec::new(), Some(panel.to_string())); + app.ui.docking_hidden_groups.retain(|group| !group.iter().any(|id| id == panel)); + Ok(result) +} + /// Show `panel` as `window.panel` does, but leave it be when it already shows ([`showing`]): a /// command that opens a panel to work in (Edit › Color Settings…) never puts it away. pub(crate) fn reveal_panel(app: &mut VectorcraftApp, panel: &str) -> Result { @@ -927,6 +961,10 @@ impl Transaction { } } let reveal = reveal.filter(|panel| layout.contains(panel)); + for group in &mut app.ui.docking_hidden_groups { + group.retain(|panel| hidden.contains_key(panel) && !layout.contains(panel)); + } + app.ui.docking_hidden_groups.retain(|group| !group.is_empty()); let flyout = reveal.as_ref().filter(|panel| in_iconic_column(&layout, panel)).cloned(); match (app.ui.docking.is_none() && hidden.is_empty()).then(|| default_flags(&app.ui, &layout)).flatten() { Some((tab, collapsed, width)) => { @@ -1188,6 +1226,9 @@ const DOCK_LABELS: &[(&str, &str)] = &[ fn floating_size(panel: &str) -> egui::Vec2 { match panel { "properties" | "layers" | "libraries" => egui::vec2(300.0, 420.0), + // Tall enough to show its Corners and Shared Edges sections, last in it, which the Corner + // Tool's and Shared Edge Merge Tool's menu items open. + crate::addons::pathtools::ID => egui::vec2(260.0, 660.0), _ => egui::vec2(260.0, 360.0), } } diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index 4bb77928b..2bae524dd 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -63,6 +63,11 @@ impl Harness { self.frame(vec![]); } + fn press(&mut self, key: egui::Key, modifiers: egui::Modifiers) { + self.frame(vec![Event::ModifiersChanged(modifiers), Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }]); + self.frame(vec![Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }]); + } + /// Press at `from` and move to `to` in steps, holding the button. fn press_and_move(&mut self, from: Pos2, to: Pos2) { self.frame(vec![Event::PointerMoved(from), button(from, true)]); @@ -497,3 +502,201 @@ fn window_menu_panels_without_a_place_open_in_their_icon_group_or_float() { h.frame(vec![]); assert!(h.tab("openType").is_some()); } + +#[test] +fn f7_hides_and_restores_the_expanded_layers_tab() { + let mut h = Harness::new(); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers"), "the first press activates the tab"); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(!showing(&h.app.ui, "layers"), "a second press hides the active tab"); + assert!(h.app.ui.docking_hidden.contains_key("layers")); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers"), "the next press restores it"); + assert_eq!(main_panels(&h.layout()), ["properties", "layers", "libraries"]); + + h.app.run("window.panel.collapse", json!({"panel": "layers", "collapsed": true})).unwrap(); + let layout = h.layout(); + let group = dock_columns(&layout).iter().flat_map(|c| &c.groups).find(|g| g.panels.contains(&"layers".to_string())).unwrap(); + assert!(group.minimized); + h.press(egui::Key::F7, egui::Modifiers::NONE); + let layout = h.layout(); + let group = dock_columns(&layout).iter().flat_map(|c| &c.groups).find(|g| g.panels.contains(&"layers".to_string())).unwrap(); + assert!(!group.minimized, "the first press expands a collapsed group"); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(!showing(&h.app.ui, "layers"), "the following press hides the expanded group"); +} + +#[test] +fn f7_activates_a_background_tab_before_hiding_it() { + let mut h = Harness::new(); + h.app.run("window.panel.move", json!({"panel": "layers", "anchor": "properties"})).unwrap(); + h.app.run("window.panel.activate", json!({"panel": "properties"})).unwrap(); + let before = group_of(&h.layout(), &"layers".into()); + assert!(!showing(&h.app.ui, "layers")); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers")); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(["properties", "layers", "libraries"].iter().all(|panel| !h.layout().contains(&panel.to_string()))); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers")); + assert_eq!(group_of(&h.layout(), &"layers".into()), before); +} + +#[test] +fn f7_hides_and_restores_the_whole_expanded_group() { + let mut h = Harness::new(); + h.press(egui::Key::F7, egui::Modifiers::NONE); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(["properties", "layers", "libraries"].iter().all(|panel| !h.layout().contains(&panel.to_string()))); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers", "libraries"]); + assert!(showing(&h.app.ui, "layers")); + h.press(egui::Key::F7, egui::Modifiers::NONE); + h.app.run("window.panel", json!({"panel": "properties", "toggle": true})).unwrap(); + assert_eq!(group_of(&h.layout(), &"properties".into()), ["properties", "layers", "libraries"]); + assert!(showing(&h.app.ui, "properties")); +} + +#[test] +fn f7_restores_only_tabs_hidden_with_the_group() { + let mut h = Harness::new(); + h.app.run("window.panel.close", json!({"panel": "libraries"})).unwrap(); + assert!(h.app.ui.docking_hidden.contains_key("libraries")); + h.press(egui::Key::F7, egui::Modifiers::NONE); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(["properties", "layers"].iter().all(|panel| h.app.ui.docking_hidden.contains_key(*panel))); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers"]); + assert!(!h.layout().contains(&"libraries".to_string())); + assert!(showing(&h.app.ui, "layers")); +} + +#[test] +fn independently_reopened_tab_is_not_restored_by_the_group_key() { + let mut h = Harness::new(); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers")); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(["properties", "layers", "libraries"].iter().all(|panel| h.app.ui.docking_hidden.contains_key(*panel))); + assert!(["properties", "layers", "libraries"].iter().all(|panel| !h.layout().contains(&panel.to_string()))); + h.app.run("window.panel.activate", json!({"panel": "libraries"})).unwrap(); + assert!(h.layout().contains(&"libraries".to_string())); + assert!(!h.app.ui.docking_hidden.contains_key("libraries")); + assert!(["properties", "layers"].iter().all(|panel| h.app.ui.docking_hidden.contains_key(*panel))); + h.app.run("window.panel.close", json!({"panel": "libraries"})).unwrap(); + assert!(h.app.ui.docking_hidden.contains_key("libraries")); + assert!(!h.layout().contains(&"libraries".to_string())); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers"]); + assert!(!h.layout().contains(&"libraries".to_string())); + assert!(showing(&h.app.ui, "layers")); +} + +#[test] +fn f7_hides_and_restores_a_floating_tab_group() { + let mut h = Harness::new(); + h.app.run("window.panel.float", json!({"panel": "layers", "group": true})).unwrap(); + h.app.run("window.panel.activate", json!({"panel": "layers"})).unwrap(); + h.frame(vec![]); + let before = h.layout(); + let window = floating_window(&before, "layers").unwrap(); + let rect = window.rect; + assert_eq!(group_of(&before, &"layers".into()), ["properties", "layers", "libraries"]); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(floating_window(&h.layout(), "layers").is_none()); + assert!(["properties", "layers", "libraries"].iter().all(|panel| !h.layout().contains(&panel.to_string()))); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(floating_window(&h.layout(), "layers").unwrap().rect, rect); + assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers", "libraries"]); + assert!(showing(&h.app.ui, "layers")); +} + +#[test] +fn f7_toggles_a_multi_tab_icon_flyout() { + let mut h = Harness::new(); + h.app.run("window.collapseDock", json!({"collapsed": true})).unwrap(); + h.frame(vec![]); + assert!(in_iconic_column(&h.layout(), "layers")); + assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers", "libraries"]); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers")); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(h.app.ui.open_panel, None); + assert!(in_iconic_column(&h.layout(), "layers")); + assert_eq!(group_of(&h.layout(), &"layers".into()), ["properties", "layers", "libraries"]); +} + +#[test] +fn panel_function_keys_toggle_icon_flyouts_and_floating_groups() { + let mut h = Harness::new(); + h.press(egui::Key::F5, egui::Modifiers::NONE); + assert_eq!(h.app.ui.open_panel.as_deref(), Some("brushes")); + h.press(egui::Key::F5, egui::Modifiers::NONE); + assert_eq!(h.app.ui.open_panel, None); + h.press(egui::Key::F5, egui::Modifiers::NONE); + assert_eq!(h.app.ui.open_panel.as_deref(), Some("brushes")); + + h.app.run("window.panel.float", json!({"panel": "layers"})).unwrap(); + h.frame(vec![]); + let rect = floating_window(&h.layout(), "layers").unwrap().rect; + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(floating_window(&h.layout(), "layers").is_none()); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(floating_window(&h.layout(), "layers").unwrap().rect, rect); + h.app.run("window.panel.dock", json!({"panel": "layers"})).unwrap(); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(!showing(&h.app.ui, "layers"), "the expanded dock also toggles"); +} + +#[test] +fn other_function_keys_and_keyboard_guards_keep_their_routes() { + let mut h = Harness::new(); + h.app.run("window.panel", json!({"panel": "layers"})).unwrap(); + let keymap = crate::shortcut_editor::current(); + for (chord, command) in [("F8", "ui.symbolOptions"), ("F12", "file.revert")] { + let bound = crate::keymap::vc_chord(chord).and_then(|c| crate::keymap::lookup(&keymap.keymap, &c)).unwrap(); + assert_eq!(bound.id, command); + } + h.app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + h.press(egui::Key::F8, egui::Modifiers::NONE); + assert_eq!(h.app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("symbolOptions")); + crate::dialogs::confirm(&mut h.app).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc.symbols.len(), 1); + crate::menus::invoke(&mut h.app, "view.rotate", json!({})); + assert!(h.app.ui.dialog.is_some()); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers"), "F7 does nothing while a dialog is open"); + h.app.ui.dialog = None; + + let field = egui::Id::unique("panel-shortcut-text-focus"); + h.ctx.memory_mut(|m| m.request_focus(field)); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(showing(&h.app.ui, "layers"), "F7 does nothing while a text field has focus"); + h.ctx.memory_mut(|m| m.surrender_focus(field)); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(!showing(&h.app.ui, "layers"), "F7 toggles when the field releases focus"); + + let dir = vectorcraft_testkit::temp_dir("panel-shortcut-revert"); + let path = dir.join(format!("panel-shortcut-revert-{}.vectorcraft", std::process::id())); + let services = crate::Services { + read: Some(Box::new(|p| std::fs::read(p).map_err(|e| e.to_string()))), + write: Some(Box::new(|p, data| std::fs::write(p, data).map_err(|e| e.to_string()))), + ..Default::default() + }; + let mut saved = VectorcraftApp::new(Session::new(), services); + saved.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + saved.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + saved.run("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + saved.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap(); + assert_eq!(saved.session.doc().unwrap().doc.node_count(), 3); + let ctx = egui::Context::default(); + let key = Event::Key { key: egui::Key::F12, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + ctx.run_ui(egui::RawInput { events: vec![key], ..Default::default() }, |ui| crate::shortcuts::handle(&mut saved, ui.ctx())) + .textures_delta + .clear(); + assert_eq!(saved.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::dialogs::confirm::KIND)); + crate::dialogs::confirm(&mut saved).unwrap(); + assert_eq!(saved.session.doc().unwrap().doc.node_count(), 2, "F12 reloads the saved document"); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/ui-egui/src/panels/gradient/tests.rs b/crates/ui-egui/src/panels/gradient/tests.rs index 7d9dcbf9e..a3b7f5cfa 100644 --- a/crates/ui-egui/src/panels/gradient/tests.rs +++ b/crates/ui-egui/src/panels/gradient/tests.rs @@ -660,7 +660,7 @@ fn the_panel_menu_saves_deletes_and_hides_presets() { let mut p = Panel::new(); let texts = p.frame_of(&mut app, vec![], Modifiers::NONE, menu); let save = text_at(&texts, "Save Gradient Preset…").unwrap_or_else(|| panic!("{texts:?}")); - assert!(!app.session.active_saved_gradient_preset().is_some()); + assert!(app.session.active_saved_gradient_preset().is_none()); p.menu_click(&mut app, save); let d = app.ui.dialog.take().expect("the name dialog"); assert_eq!((d.fields["__command"].as_str(), d.fields["name"].as_str()), (Some("gradient.presets.save"), Some("Gradient Preset 1"))); diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 409020e3b..8fba3604d 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -381,6 +381,13 @@ fn rename(ui: &mut UiState, from: &str, to: &str) -> Result<(), String> { } ui.docking = docking; ui.docking_hidden = hidden; + for group in &mut ui.docking_hidden_groups { + for panel in group { + if panel == from { + *panel = to.to_string(); + } + } + } if ui.open_panel.as_deref() == Some(from) { ui.open_panel = Some(to.into()); } diff --git a/crates/ui-egui/src/panels/swatches/body.rs b/crates/ui-egui/src/panels/swatches/body.rs index 9147960ba..611b12d6d 100644 --- a/crates/ui-egui/src/panels/swatches/body.rs +++ b/crates/ui-egui/src/panels/swatches/body.rs @@ -159,6 +159,7 @@ pub(super) fn focus_swatch(app: &VectorcraftApp, ui: &Ui, active: Option<&str>) let reselected = last_art.as_ref().is_some_and(|l| *l != art); if reselected { set_pstate(ui.ctx(), "swatch-selected", Vec::::new()); + set_pstate(ui.ctx(), "swatch-anchor", String::new()); } let changed = reselected || last_art.is_none() || last_swatch.as_deref() != active; set_pstate(ui.ctx(), "swatch-focus", (Some(art), active.map(str::to_string))); diff --git a/crates/ui-egui/src/panels/swatches/tests.rs b/crates/ui-egui/src/panels/swatches/tests.rs index 7d751fe9d..c65941370 100644 --- a/crates/ui-egui/src/panels/swatches/tests.rs +++ b/crates/ui-egui/src/panels/swatches/tests.rs @@ -133,6 +133,21 @@ fn modifier_clicks_select_several_swatches_and_groups() { assert_eq!(selected(&ctx), ["Grays"], "a plain click on a group selects it alone"); } +#[test] +fn switching_documents_clears_the_swatch_range_anchor() { + let mut app = app(); + let ctx = context(); + click_tile(&mut app, &ctx, "White", Modifiers::NONE, 0.0); + assert_eq!(selected(&ctx), ["White"]); + + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + frame(&mut app, &ctx, vec![], 1.0, show); + assert!(selected(&ctx).is_empty()); + + click_tile(&mut app, &ctx, "Black", Modifiers::SHIFT, 2.0); + assert_eq!(selected(&ctx), ["Black"]); +} + /// A color swatch or a recent color clicked in the panel becomes the Color Guide's base color /// (#3137); a modifier click only selects. #[test] diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 9eba2af7b..040ff676c 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -40,9 +40,189 @@ fn align_id(a: TabAlign) -> &'static str { /// The panel body's margin and the ruler's own inset: the ruler's 0 is this far right of the panel. const RULER_LEFT: f32 = 10.0 + 4.0; -/// The text object, the stops of the caret's paragraph while the Type tool edits text (else the -/// first selected text object's first paragraph) and the ruler span in points (the frame width for -/// area type). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum IndentMarker { + Upper, + Lower, +} + +#[derive(Clone)] +struct IndentTarget { + id: u64, + start: usize, + left: f64, + first_line: f64, +} + +fn append_indent_targets(node: &Node, range: std::ops::Range, targets: &mut Vec) { + let NodeKind::Text(text) = &node.kind else { return }; + let plain = text.plain_text(); + let mut starts = vec![0]; + starts.extend(plain.bytes().enumerate().filter_map(|(i, c)| (c == b'\n').then_some(i + 1))); + targets.extend(range.filter_map(|i| { + let para = text.para_at(i); + Some(IndentTarget { id: node.id.0, start: *starts.get(i)?, left: para.left_indent, first_line: para.first_line_indent }) + })); +} + +/// Paragraphs touched by the Type selection, or all paragraphs of selected text objects. +fn indent_targets(app: &VectorcraftApp, node: &Node) -> Vec { + let mut targets = Vec::new(); + if let Some((_, a, b)) = super::character::text_editing(app) { + if let NodeKind::Text(text) = &node.kind { + append_indent_targets(node, text.paragraphs_in(a, b), &mut targets); + } + } else if let Some(st) = app.session.active() { + for selected in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { + selected.walk(&mut |child| { + if let NodeKind::Text(text) = &child.kind { + append_indent_targets(child, 0..text.paragraph_count(), &mut targets); + } + }); + } + } + targets +} + +fn marker_position(marker: IndentMarker, para: &ParaStyle) -> f64 { + match marker { + IndentMarker::Upper => para.left_indent + para.first_line_indent, + IndentMarker::Lower => para.left_indent, + } +} + +fn bounded_marker_position(marker: IndentMarker, target: f64, span: f64, targets: &[IndentTarget], both: bool) -> Option { + if !target.is_finite() || !span.is_finite() || span < 0.0 || targets.is_empty() { + return None; + } + let (low, high) = match marker { + IndentMarker::Upper => { + (targets.iter().map(|p| p.left - 1296.0).fold(0.0_f64, f64::max), targets.iter().map(|p| p.left + 1296.0).fold(span, f64::min)) + } + IndentMarker::Lower if both => ( + targets.iter().map(|p| -p.first_line).fold(0.0_f64, f64::max), + targets.iter().map(|p| span - p.first_line).fold(span.min(1296.0), f64::min), + ), + IndentMarker::Lower => ( + targets.iter().map(|p| p.left + p.first_line - 1296.0).fold(0.0_f64, f64::max), + targets.iter().map(|p| p.left + p.first_line + 1296.0).fold(span.min(1296.0), f64::min), + ), + }; + (low <= high).then(|| target.clamp(low, high)) +} + +fn hit_marker(pos: egui::Pos2, rect: egui::Rect, para: &ParaStyle, span: f64, mixed_left: bool, to_x: impl Fn(f64) -> f32) -> Option { + if pos.y < rect.top() || pos.y > rect.top() + 16.0 { + return None; + } + let upper = (to_x(marker_position(IndentMarker::Upper, para).clamp(0.0, span)) - pos.x).abs() <= 6.0; + let lower = !mixed_left && (to_x(marker_position(IndentMarker::Lower, para).clamp(0.0, span)) - pos.x).abs() <= 6.0; + match (upper, lower) { + (true, true) if pos.y >= rect.top() + 8.0 => Some(IndentMarker::Lower), + (true, _) => Some(IndentMarker::Upper), + (_, true) => Some(IndentMarker::Lower), + _ => None, + } +} + +struct MarkerPaint<'a> { + rect: egui::Rect, + span: f64, + para: &'a ParaStyle, + mixed_left: bool, + selected: Option, + drag: Option<(IndentMarker, bool)>, + preview: Option<(IndentMarker, f64, bool)>, +} + +fn paint_markers(ui: &mut Ui, painter: &egui::Painter, tokens: &Tokens, view: MarkerPaint<'_>, to_x: impl Fn(f64) -> f32) { + for marker in [IndentMarker::Upper, IndentMarker::Lower] { + if marker == IndentMarker::Lower && view.mixed_left { + continue; + } + let value = match view.preview { + Some((IndentMarker::Upper, x, _)) if marker == IndentMarker::Upper => x, + Some((IndentMarker::Lower, x, _)) if marker == IndentMarker::Lower => x, + Some((IndentMarker::Lower, x, true)) if marker == IndentMarker::Upper => marker_position(marker, view.para) + x - view.para.left_indent, + _ => marker_position(marker, view.para), + }; + let x = to_x(value.clamp(0.0, view.span)); + let top = view.rect.top() + 1.0; + let points = if marker == IndentMarker::Upper { + vec![egui::pos2(x - 5.0, top), egui::pos2(x + 5.0, top), egui::pos2(x, top + 7.0)] + } else { + vec![egui::pos2(x - 5.0, top + 15.0), egui::pos2(x + 5.0, top + 15.0), egui::pos2(x, top + 8.0)] + }; + let selected = view.selected == Some(marker) || view.drag.is_some_and(|(m, _)| m == marker); + painter.add(egui::Shape::convex_polygon(points, tokens.text, Stroke::new(1.5, if selected { tokens.accent } else { tokens.text_dim }))); + let name = match marker { + IndentMarker::Upper => tl!("First-line Left Indent"), + IndentMarker::Lower => tl!("Left Indent"), + }; + let hit = + egui::Rect::from_center_size(egui::pos2(x, view.rect.top() + if marker == IndentMarker::Upper { 4.0 } else { 12.0 }), vec2(12.0, 8.0)); + let hover = ui.interact(hit, ui.scope_id().with(("tabs-indent", marker as u8)), Sense::hover()); + hover.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, name)); + hover.on_hover_text(name); + } +} + +/// Apply an absolute ruler position through paragraph-format commands. +fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: &ParaStyle, span: f64, targets: &[IndentTarget], both: bool) { + if targets.is_empty() || !target.is_finite() { + return; + } + let Some(target) = bounded_marker_position(marker, target, span, targets, both) else { return }; + if marker == IndentMarker::Upper { + if targets.iter().all(|p| p.left == para.left_indent) { + if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", json!({"firstLineIndent": target - para.left_indent})) { + app.status(e); + } + } else { + super::character::end_typing(app); + let commands: Vec = targets + .iter() + .map(|p| { + json!({"command": "text.setFormat", "params": { + "ids": [p.id], "start": p.start, "end": p.start, "firstLineIndent": target - p.left + }}) + }) + .collect(); + if let Err(e) = app.run("command.batch", json!({"label": tl!("First-line Left Indent"), "commands": commands})) { + app.status(e); + } + } + } else if both { + if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", json!({"leftIndent": target})) { + app.status(e); + } + } else if targets.iter().all(|p| p.left + p.first_line == marker_position(IndentMarker::Upper, para)) { + if let Err(e) = super::paragraph::run_on_paragraphs( + app, + "text.setFormat", + json!({"leftIndent": target, "firstLineIndent": marker_position(IndentMarker::Upper, para) - target}), + ) { + app.status(e); + } + } else { + super::character::end_typing(app); + let commands: Vec = targets + .iter() + .map(|p| { + json!({"command": "text.setFormat", "params": { + "ids": [p.id], "start": p.start, "end": p.start, + "leftIndent": target, "firstLineIndent": p.left + p.first_line - target + }}) + }) + .collect(); + if let Err(e) = app.run("command.batch", json!({"label": tl!("Left Indent"), "commands": commands})) { + app.status(e); + } + } +} + +/// The text object, the stops of the active paragraph and the ruler span for panel tests. +#[cfg(test)] fn current(app: &VectorcraftApp) -> Option<(Node, Vec, f64)> { current_para(app).map(|(n, para, span)| (n, para.tabs, span)) } @@ -124,10 +304,18 @@ fn apply_moved(app: &mut VectorcraftApp, ctx: &egui::Context, stops: &mut Vec = pstate(ui.ctx(), "tabs-marker-sel"); + let selected_marker = stored_marker.filter(|marker| !mixed_left || *marker != IndentMarker::Lower); + if selected_marker != stored_marker { + set_pstate::>(ui.ctx(), "tabs-marker-sel", None); + } let mut sel: usize = pstate(ui.ctx(), "tabs-sel"); if sel >= stops.len() { sel = stops.len().saturating_sub(1); @@ -137,14 +325,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 3.0; for (i, (a, label)) in ALIGNS.iter().enumerate() { - let on = stops.get(sel).map_or(align_idx == i, |s| s.align == *a); + let on = if selected_marker.is_some() { align_idx == i } else { stops.get(sel).map_or(align_idx == i, |s| s.align == *a) }; let tip = align_name(*a, vertical); let resp = ui.add(egui::Button::new(egui::RichText::new(*label).size(13.0)).selected(on).min_size(vec2(28.0, 24.0))); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), on, tip)); if resp.on_hover_text(tip).clicked() { align_idx = i; set_pstate(ui.ctx(), "tabs-align", i); - if let Some(s) = stops.get_mut(sel) { + if let Some(s) = stops.get_mut(sel).filter(|_| selected_marker.is_none()) { s.align = *a; apply(app, &stops); } @@ -159,18 +347,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.add_space(4.0); egui::Grid::new("tabs-grid").num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| { ui.label(egui::RichText::new(if vertical { tl!("Y:") } else { tl!("X:") }).color(t.text)); - let x = stops.get(sel).map(|s| s.position); - if let Some(v) = widgets::spin_field(ui, "tabs-x", x, app.session.general_unit(), 90.0, 1.0, 0.0, &[]) - && let Some(i) = apply_moved(app, ui.ctx(), &mut stops, sel, v.max(0.0)) - { - sel = i; + let x = selected_marker.map(|m| marker_position(m, ¶)).or_else(|| stops.get(sel).map(|s| s.position)); + if let Some(v) = widgets::spin_field(ui, "tabs-x", x, app.session.general_unit(), 90.0, 1.0, 0.0, &[]) { + if let Some(marker) = selected_marker { + set_marker(app, marker, v, ¶, span, &targets, false); + } else if let Some(i) = apply_moved(app, ui.ctx(), &mut stops, sel, v.max(0.0)) { + sel = i; + } } ui.end_row(); ui.label(egui::RichText::new(tl!("Leader:")).color(t.text)); // The fields keep what is typed until Enter or a click away commits it (#924). - let leader = stops.get(sel).map(|s| s.leader.clone()).unwrap_or_default(); + let leader = stops.get(sel).filter(|_| selected_marker.is_none()).map(|s| s.leader.clone()).unwrap_or_default(); let typed = ui - .add_enabled_ui(stops.get(sel).is_some(), |ui| widgets::exact_text_field_named(ui, "tabs-leader", Some(tl!("Leader:")), &leader, 90.0)) + .add_enabled_ui(stops.get(sel).is_some() && selected_marker.is_none(), |ui| { + widgets::exact_text_field_named(ui, "tabs-leader", Some(tl!("Leader:")), &leader, 90.0) + }) .inner; if let (Some(leader), Some(s)) = (typed, stops.get_mut(sel)) { // Up to eight characters repeat in the leader. @@ -179,7 +371,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.end_row(); ui.label(egui::RichText::new(tl!("Align On:")).color(t.text)); - let decimal = stops.get(sel).is_some_and(|s| s.align == TabAlign::Decimal); + let decimal = selected_marker.is_none() && stops.get(sel).is_some_and(|s| s.align == TabAlign::Decimal); let on = stops.get(sel).map(|s| s.align_on.to_string()).unwrap_or_else(|| ".".into()); let typed = ui.add_enabled_ui(decimal, |ui| widgets::exact_text_field_named(ui, "tabs-align-on", Some(tl!("Align On:")), &on, 30.0)).inner; if let (Some(c), Some(s)) = (typed.and_then(|t| t.chars().next()), stops.get_mut(sel)) { @@ -226,9 +418,20 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // or Shift held, it lands on the ruler's nearest tick, which can be a fraction of a point. let snap = pstate::(ui.ctx(), "tabs-snap") || ui.input(|i| i.modifiers.shift); let place = |x: f32| if snap { unit.snap_to_ruler_tick(to_pt(x), scale).max(0.0) } else { to_pt(x).round() }; + let stored_drag: Option<(IndentMarker, bool)> = pstate(ui.ctx(), "tabs-marker-drag"); + let marker_drag = stored_drag.filter(|(marker, _)| !mixed_left || *marker != IndentMarker::Lower); + if marker_drag != stored_drag { + set_pstate::>(ui.ctx(), "tabs-marker-drag", None); + } + let preview = marker_drag.and_then(|(marker, both)| { + let pointer = ui.input(|i| i.pointer.interact_pos())?; + let value = bounded_marker_position(marker, place(pointer.x), span, &targets, both)?; + Some((marker, value, both)) + }); + paint_markers(ui, &p, &t, MarkerPaint { rect, span, para: ¶, mixed_left, selected: selected_marker, drag: marker_drag, preview }, to_x); for (i, s) in stops.iter().enumerate() { let x = to_x(s.position); - let c = if i == sel { t.accent } else { t.text }; + let c = if i == sel && selected_marker.is_none() { t.accent } else { t.text }; let y = rect.bottom() - 2.0; // An original marker: a stem with a foot showing which way the text runs. p.line_segment([egui::pos2(x, y - 12.0), egui::pos2(x, y)], Stroke::new(1.5, c)); @@ -245,18 +448,39 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let drag: Option = pstate(ui.ctx(), "tabs-drag"); // The stop within 5 px of `x`. let hit_at = |x: f32| stops.iter().position(|s| (to_x(s.position) - x).abs() <= 5.0); + let cancel_drag = ui.input(|i| i.key_pressed(egui::Key::Escape)); + if cancel_drag || (marker_drag.is_some() && !resp.drag_stopped() && !ui.input(|i| i.pointer.any_down())) { + set_pstate::>(ui.ctx(), "tabs-marker-drag", None); + set_pstate::>(ui.ctx(), "tabs-drag", None); + } // egui reports the drag only once the pointer is a few pixels from the press, past the hit // tolerance, so hit-test where the button went down (#1458). if resp.drag_started() && let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) { - let hit = hit_at(pos.x); - set_pstate(ui.ctx(), "tabs-drag", hit); - if let Some(i) = hit { - set_pstate(ui.ctx(), "tabs-sel", i); + if let Some(marker) = hit_marker(pos, rect, ¶, span, mixed_left, to_x) { + let both = marker == IndentMarker::Lower && ui.input(|i| i.modifiers.command); + set_pstate(ui.ctx(), "tabs-marker-sel", Some(marker)); + set_pstate(ui.ctx(), "tabs-marker-drag", Some((marker, both))); + set_pstate::>(ui.ctx(), "tabs-drag", None); + } else { + let hit = hit_at(pos.x); + set_pstate(ui.ctx(), "tabs-drag", hit); + if let Some(i) = hit { + set_pstate::>(ui.ctx(), "tabs-marker-sel", None); + set_pstate(ui.ctx(), "tabs-sel", i); + } } } if resp.drag_stopped() + && !cancel_drag + && let Some((marker, both)) = marker_drag + { + set_pstate::>(ui.ctx(), "tabs-marker-drag", None); + if let Some(pos) = resp.interact_pointer_pos() { + set_marker(app, marker, place(pos.x), ¶, span, &targets, both); + } + } else if resp.drag_stopped() && let (Some(i), Some(pos)) = (drag, resp.interact_pointer_pos()) { set_pstate::>(ui.ctx(), "tabs-drag", None); @@ -272,21 +496,29 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } else if resp.clicked() && let Some(pos) = resp.interact_pointer_pos() { - match hit_at(pos.x) { - Some(i) => set_pstate(ui.ctx(), "tabs-sel", i), - None => { - let position = place(pos.x); - // A click that lands on a stop's whole point or tick selects that stop and records no - // undo step. - let added = !stops.iter().any(|s| s.position == position); - if added { - stops.push(TabStop { position, align: ALIGNS[align_idx.min(3)].0, leader: String::new(), align_on: '.' }); - stops.sort_by(|a, b| a.position.total_cmp(&b.position)); + if let Some(marker) = hit_marker(pos, rect, ¶, span, mixed_left, to_x) { + set_pstate(ui.ctx(), "tabs-marker-sel", Some(marker)); + } else { + match hit_at(pos.x) { + Some(i) => { + set_pstate::>(ui.ctx(), "tabs-marker-sel", None); + set_pstate(ui.ctx(), "tabs-sel", i); } - let i = stops.iter().position(|s| s.position == position).unwrap_or(0); - set_pstate(ui.ctx(), "tabs-sel", i); - if added { - apply(app, &stops); + None => { + let position = place(pos.x); + // A click that lands on a stop's whole point or tick selects that stop and records no + // undo step. + let added = !stops.iter().any(|s| s.position == position); + if added { + stops.push(TabStop { position, align: ALIGNS[align_idx.min(3)].0, leader: String::new(), align_on: '.' }); + stops.sort_by(|a, b| a.position.total_cmp(&b.position)); + } + let i = stops.iter().position(|s| s.position == position).unwrap_or(0); + set_pstate::>(ui.ctx(), "tabs-marker-sel", None); + set_pstate(ui.ctx(), "tabs-sel", i); + if added { + apply(app, &stops); + } } } } @@ -301,6 +533,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { return; }; let mut stops = para.tabs; + let selected_marker: Option = pstate(ui.ctx(), "tabs-marker-sel"); // Clamp the selection to the last stop, as `show` does for the ruler. let mut sel: usize = pstate(ui.ctx(), "tabs-sel"); if sel >= stops.len() { @@ -311,13 +544,13 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { { app.status(e); } - if menu_item(ui, tl!("Delete Tab"), sel < stops.len(), false) { + if menu_item(ui, tl!("Delete Tab"), sel < stops.len() && selected_marker.is_none(), false) { stops.remove(sel); apply(app, &stops); } // Repeat Tab: copies of the selected stop across the ruler, spaced by its distance from the // previous stop, or from the paragraph's left indent when it is the first stop. - if menu_item(ui, tl!("Repeat Tab"), sel < stops.len(), false) { + if menu_item(ui, tl!("Repeat Tab"), sel < stops.len() && selected_marker.is_none(), false) { let step = match sel { 0 => (stops[0].position - para.left_indent).abs(), _ => stops[sel].position - stops[sel - 1].position, diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index e66f5860f..ad8f86a52 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -249,6 +249,244 @@ fn drag_on_ruler(ctx: &egui::Context, app: &mut VectorcraftApp, from: f64, to: f ruler_frame(ctx, app, vec![], shift); } +fn indent_app(left: f64, first: f64) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + app.run("text.create", json!({"x": 10, "y": 20, "text": "one\ntwo", "area": {"width": 300, "height": 100}})).unwrap(); + app.run("text.setFormat", json!({"leftIndent": left, "firstLineIndent": first})).unwrap(); + app +} + +fn indent_values(app: &VectorcraftApp, paragraph: usize) -> (f64, f64) { + let (node, _, _) = current(app).unwrap(); + let NodeKind::Text(t) = node.kind else { panic!("selected text") }; + let p = t.para_at(paragraph); + (p.left_indent, p.first_line_indent) +} + +fn marker_event(ctx: &egui::Context, app: &mut VectorcraftApp, from: f64, to: f64, upper: bool, command: bool, shift: bool) { + let labels = ruler_frame(ctx, app, vec![], shift); + let [(zero, a), (next, b), ..] = &labels[..] else { panic!("ruler labels {labels:?}") }; + assert_eq!(zero, "0"); + let per = next.parse::().unwrap() * app.session.general_unit().points(); + let x = |pt: f64| a.x - 2.0 + (pt / per) as f32 * (b.x - a.x); + let y = a.y + if upper { 3.0 } else { 11.0 }; + let (press, end) = (egui::pos2(x(from), y), egui::pos2(x(to), y)); + let modifiers = match (command, shift) { + (true, true) => egui::Modifiers { command: true, shift: true, ..Default::default() }, + (true, false) => egui::Modifiers::COMMAND, + (false, true) => egui::Modifiers::SHIFT, + (false, false) => egui::Modifiers::NONE, + }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + ruler_frame(ctx, app, vec![egui::Event::ModifiersChanged(modifiers), egui::Event::PointerMoved(press), button(press, true)], shift); + if press != end { + for f in [0.25, 0.5, 1.0] { + ruler_frame(ctx, app, vec![egui::Event::ModifiersChanged(modifiers), egui::Event::PointerMoved(press.lerp(end, f))], shift); + } + } + ruler_frame(ctx, app, vec![egui::Event::ModifiersChanged(modifiers), button(end, false)], shift); + ruler_frame(ctx, app, vec![], shift); +} + +#[test] +fn indent_markers_show_positions_and_select_without_adding_stops() { + let mut app = indent_app(24.0, 12.0); + let ctx = egui::Context::default(); + assert_eq!(marker_position(IndentMarker::Lower, ¤t_para(&app).unwrap().1), 24.0); + assert_eq!(marker_position(IndentMarker::Upper, ¤t_para(&app).unwrap().1), 36.0); + let labels = ruler_frame(&ctx, &mut app, vec![], false); + let [(zero, a), (next, b), ..] = &labels[..] else { panic!("ruler labels {labels:?}") }; + assert_eq!(zero, "0"); + let per = next.parse::().unwrap() * app.session.general_unit().points(); + let x = |pt: f64| a.x - 2.0 + (pt / per) as f32 * (b.x - a.x); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| show(&mut app, ui)); + out.textures_delta.clear(); + let triangles: Vec = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Path(path) if path.points.len() == 3 && path.points.iter().all(|p| p.y >= a.y && p.y <= a.y + 16.0) => { + Some(egui::pos2(path.points.iter().map(|p| p.x).sum::() / 3.0, path.points.iter().map(|p| p.y).sum::() / 3.0)) + } + _ => None, + }) + .collect(); + assert!(triangles.iter().any(|p| (p.x - x(36.0)).abs() < 0.5 && p.y < a.y + 8.0), "upper marker at 36 pt: {triangles:?}"); + assert!(triangles.iter().any(|p| (p.x - x(24.0)).abs() < 0.5 && p.y > a.y + 8.0), "lower marker at 24 pt: {triangles:?}"); + marker_event(&ctx, &mut app, 36.0, 36.0, true, false, false); + assert_eq!(pstate::>(&ctx, "tabs-marker-sel"), Some(IndentMarker::Upper)); + assert!(current(&app).unwrap().1.is_empty()); + marker_event(&ctx, &mut app, 24.0, 24.0, false, false, false); + assert_eq!(pstate::>(&ctx, "tabs-marker-sel"), Some(IndentMarker::Lower)); + assert!(current(&app).unwrap().1.is_empty()); +} + +#[test] +fn upper_and_lower_marker_drags_map_indents_and_undo_once() { + let mut app = indent_app(24.0, 12.0); + let ctx = egui::Context::default(); + let before = app.session.active().unwrap().history.undo.len(); + marker_event(&ctx, &mut app, 36.0, 60.0, true, false, false); + assert_eq!(indent_values(&app, 0), (24.0, 36.0)); + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(indent_values(&app, 0), (24.0, 12.0)); + marker_event(&ctx, &mut app, 24.0, 40.0, false, false, false); + assert_eq!(indent_values(&app, 0), (40.0, -4.0)); + marker_event(&ctx, &mut app, 40.0, 50.0, false, true, false); + assert_eq!(indent_values(&app, 0), (50.0, -4.0)); +} + +#[test] +fn marker_shift_drag_snaps_and_coincident_halves_remain_separate() { + let mut app = indent_app(24.0, 0.0); + let ctx = egui::Context::default(); + marker_event(&ctx, &mut app, 24.0, 33.4, true, false, true); + assert_eq!(indent_values(&app, 0), (24.0, 11.0)); + app.run("text.setFormat", json!({"leftIndent": 24, "firstLineIndent": 0})).unwrap(); + marker_event(&ctx, &mut app, 24.0, 40.0, false, false, false); + assert_eq!(indent_values(&app, 0), (40.0, -16.0)); +} + +#[test] +fn mixed_indent_upper_marker_keeps_each_left_indent_in_one_undo_step() { + let mut app = indent_app(24.0, 12.0); + let id = current(&app).unwrap().0.id.0; + app.run("text.setFormat", json!({"id": id, "start": 4, "end": 4, "leftIndent": 60, "firstLineIndent": -10})).unwrap(); + let ctx = egui::Context::default(); + let before = app.session.active().unwrap().history.undo.len(); + marker_event(&ctx, &mut app, 36.0, 70.0, true, false, false); + assert_eq!(indent_values(&app, 0), (24.0, 46.0)); + assert_eq!(indent_values(&app, 1), (60.0, 10.0)); + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(indent_values(&app, 0), (24.0, 12.0)); + assert_eq!(indent_values(&app, 1), (60.0, -10.0)); + marker_event(&ctx, &mut app, 60.0, 60.0, false, false, false); + assert_ne!(pstate::>(&ctx, "tabs-marker-sel"), Some(IndentMarker::Lower)); + + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("text.create", json!({"x": 10, "y": 20, "text": "one", "area": {"width": 300, "height": 100}})).unwrap()["id"].clone(); + let b = app.run("text.create", json!({"x": 10, "y": 140, "text": "two", "area": {"width": 300, "height": 100}})).unwrap()["id"].clone(); + app.run("text.setFormat", json!({"id": a, "leftIndent": 24, "firstLineIndent": 12})).unwrap(); + app.run("text.setFormat", json!({"id": b, "leftIndent": 60, "firstLineIndent": -10})).unwrap(); + app.run("select.set", json!({"ids": [a, b.clone()]})).unwrap(); + let ctx = egui::Context::default(); + assert_eq!(current_para(&app).unwrap().1.left_indent, 24.0); + marker_event(&ctx, &mut app, 36.0, 70.0, true, false, false); + assert_eq!(indent_values(&app, 0), (24.0, 46.0)); + let second = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(b.as_u64().unwrap())).unwrap(); + let NodeKind::Text(second) = &second.kind else { panic!("second text") }; + assert_eq!((second.para_at(0).left_indent, second.para_at(0).first_line_indent), (60.0, 10.0)); +} + +#[test] +fn lower_marker_keeps_each_first_line_position_in_one_undo_step() { + let mut app = indent_app(24.0, 12.0); + let id = current(&app).unwrap().0.id.0; + app.run("text.setFormat", json!({"id": id, "start": 4, "end": 7, "firstLineIndent": 28})).unwrap(); + assert_eq!(indent_values(&app, 0), (24.0, 12.0)); + assert_eq!(indent_values(&app, 1), (24.0, 28.0)); + let ctx = egui::Context::default(); + let before = app.session.active().unwrap().history.undo.len(); + marker_event(&ctx, &mut app, 24.0, 40.0, false, false, false); + assert_eq!(indent_values(&app, 0), (40.0, -4.0)); + assert_eq!(indent_values(&app, 1), (40.0, 12.0)); + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(indent_values(&app, 0), (24.0, 12.0)); + assert_eq!(indent_values(&app, 1), (24.0, 28.0)); + marker_event(&ctx, &mut app, 24.0, 40.0, false, true, false); + assert_eq!(indent_values(&app, 0), (40.0, 12.0)); + assert_eq!(indent_values(&app, 1), (40.0, 28.0)); +} + +#[test] +fn lower_marker_bounds_cover_each_touched_first_line() { + let targets = [IndentTarget { id: 1, start: 0, left: 24.0, first_line: -20.0 }, IndentTarget { id: 1, start: 4, left: 24.0, first_line: 280.0 }]; + assert_eq!(bounded_marker_position(IndentMarker::Lower, 40.0, 300.0, &targets, true), Some(20.0)); + assert_eq!(bounded_marker_position(IndentMarker::Lower, 1200.0, 300.0, &targets, false), Some(300.0)); +} + +#[test] +fn hidden_lower_selection_returns_x_field_to_tab_stop() { + let mut app = indent_app(24.0, 12.0); + app.run("text.tabs.set", json!({"stops": [{"position": 100}]})).unwrap(); + let ctx = egui::Context::default(); + marker_event(&ctx, &mut app, 24.0, 24.0, false, false, false); + assert_eq!(pstate::>(&ctx, "tabs-marker-sel"), Some(IndentMarker::Lower)); + let id = current(&app).unwrap().0.id.0; + app.run("text.setFormat", json!({"id": id, "start": 4, "end": 7, "leftIndent": 60})).unwrap(); + assert_eq!(indent_values(&app, 0), (24.0, 12.0)); + assert_eq!(indent_values(&app, 1), (60.0, 12.0)); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)); + let frame = |app: &mut VectorcraftApp, events| { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.visual_bounding_rect())), + _ => None, + }) + .collect::>() + }; + let texts = frame(&mut app, vec![]); + let at = texts.iter().find(|(text, _)| text == "100 pt").unwrap_or_else(|| panic!("tab X field missing: {texts:?}")).1.center(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(&mut app, vec![egui::Event::PointerMoved(at), button(true)]); + frame(&mut app, vec![button(false)]); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + frame(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); + frame(&mut app, vec![egui::Event::Text("120".into())]); + frame(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); + assert_eq!(indent_values(&app, 0).0, 24.0); + assert_eq!(indent_values(&app, 1).0, 60.0); + assert_eq!(current(&app).unwrap().1[0].position, 120.0); +} + +#[test] +fn selected_marker_position_field_sets_its_absolute_position() { + let mut app = indent_app(24.0, 12.0); + let ctx = egui::Context::default(); + marker_event(&ctx, &mut app, 36.0, 36.0, true, false, false); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0)); + let field_frame = |app: &mut VectorcraftApp, events| { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.visual_bounding_rect())), + _ => None, + }) + .collect::>() + }; + let texts = field_frame(&mut app, vec![]); + let at = texts.iter().find(|(text, _)| text == "36 pt").unwrap_or_else(|| panic!("36 pt in {texts:?}")).1.center(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + field_frame(&mut app, vec![egui::Event::PointerMoved(at), button(true)]); + field_frame(&mut app, vec![button(false)]); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + field_frame(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); + field_frame(&mut app, vec![egui::Event::Text("80".into())]); + field_frame(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); + assert_eq!(indent_values(&app, 0), (24.0, 56.0)); + marker_event(&ctx, &mut app, 24.0, 24.0, false, false, false); + let texts = field_frame(&mut app, vec![]); + let at = texts.iter().find(|(text, _)| text == "24 pt").unwrap_or_else(|| panic!("24 pt in {texts:?}")).1.center(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + field_frame(&mut app, vec![egui::Event::PointerMoved(at), button(true)]); + field_frame(&mut app, vec![button(false)]); + field_frame(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); + field_frame(&mut app, vec![egui::Event::Text("40".into())]); + field_frame(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); + assert_eq!(indent_values(&app, 0), (40.0, 40.0)); +} + /// The ruler is numbered in the General unit (the document's units), with labels at least 50 px /// apart as on the canvas rulers. #[test] @@ -550,6 +788,22 @@ fn panel_menu_repeats_deletes_and_clears_tab_stops() { assert_eq!(positions(&app).len(), 21); } +#[test] +fn panel_menu_does_not_change_stops_while_an_indent_marker_is_selected() { + let mut app = indent_app(24.0, 12.0); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}, {"position": 70}]})).unwrap(); + let ctx = egui::Context::default(); + set_pstate(&ctx, "tabs-sel", 1usize); + marker_event(&ctx, &mut app, 36.0, 36.0, true, false, false); + assert_eq!(pstate::>(&ctx, "tabs-marker-sel"), Some(IndentMarker::Upper)); + let before = current(&app).unwrap().1; + + choose(&ctx, &mut app, "Repeat Tab"); + assert_eq!(current(&app).unwrap().1, before); + choose(&ctx, &mut app, "Delete Tab"); + assert_eq!(current(&app).unwrap().1, before); +} + /// Repeat Tab on the first stop spaces the copies by the stop's distance from the paragraph's /// left indent, from the paragraph the caret is in while the Type tool edits text. #[test] diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 695ace916..4d6164f11 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -506,6 +506,17 @@ pub fn reset_one(overrides: &mut Overrides, key: &str) { overrides.remove(&crate::keymap::stored_key(key)); } +/// Text shown for all active chords in a dialog row. +fn dialog_shortcuts_with(km: &Keymap, key: &str) -> Option { + let bindings = km.bindings(&command_of(key)); + if bindings.len() > 1 { + let primary = effective_with(km, key).map(|s| menus::pretty_shortcut(&s))?; + let additional = bindings.iter().skip(1).map(|b| menus::pretty_shortcut(&crate::keymap::binding_text(b))); + return Some(std::iter::once(primary).chain(additional).collect::>().join(" · ")); + } + effective_with(km, key).map(|s| menus::pretty_shortcut(&s)) +} + /// Overrides of a preset (both presets are the registry defaults for now). pub fn preset(name: &str) -> Option { PRESETS.contains(&set_name(name)).then(Overrides::new) @@ -695,7 +706,7 @@ struct DialogView { impl DialogView { fn new(overrides: Overrides) -> DialogView { let keymap = keymap_with(&overrides); - let texts = entries().iter().map(|e| effective_with(&keymap, &e.key)).collect(); + let texts = entries().iter().map(|e| dialog_shortcuts_with(&keymap, &e.key)).collect(); let conflicts = conflicts_in(&keymap).len(); DialogView { source: stored::to_value(keymap.overrides()), keymap, texts, conflicts } } @@ -812,7 +823,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { for (i, e) in entries().iter().enumerate().filter(|(_, e)| e.is_tool == tools && listed(km.overrides(), e)) { let sc = match &km { Cow::Borrowed(_) => view.texts.get(i).cloned().flatten(), - Cow::Owned(km) => effective_with(km, &e.key), + Cow::Owned(km) => dialog_shortcuts_with(km, &e.key), }; let matches = e.label.to_lowercase().contains(&q) || tl!(&e.label).to_lowercase().contains(&q) @@ -837,11 +848,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if r.clicked() { d.fields.insert("__selected".into(), json!(e.key)); } - let label = if rec == e.key { - tl!("Press keys…").to_string() - } else { - sc.as_deref().map(menus::pretty_shortcut).unwrap_or_else(|| "—".into()) - }; + let label = if rec == e.key { tl!("Press keys…").to_string() } else { sc.unwrap_or_else(|| "—".into()) }; let changed = crate::keymap::has_override(km.overrides(), &e.key); let txt = egui::RichText::new(label).color(if changed { t.accent } else { t.text }); let b = ui @@ -1224,6 +1231,18 @@ mod tests { assert!(preset("Nope").is_none()); } + #[test] + fn paint_dialog_rows_show_both_default_keys() { + let view = DialogView::new(Overrides::new()); + for (key, plain, shifted) in [("paint.lastColor", ",", "Shift+Comma"), ("paint.lastGradient", ".", "Shift+Period")] { + let at = entries().iter().position(|e| e.key == key).unwrap(); + let shown = view.texts[at].as_deref().unwrap(); + let shifted = menus::pretty_shortcut(shifted); + assert!(shown.contains(plain) && shown.contains(&shifted), "{key}: {shown}"); + } + assert_eq!(view.conflicts, 0); + } + #[test] fn entries_cover_tools_and_menu_commands() { assert!(entries().iter().any(|e| e.key == "tool:selection")); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 76d6b3967..bb0cb06e0 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -24,6 +24,22 @@ use typing::is_shortcut_chord; pub(crate) use typing::keep_marked_text; use typing::{drop_typing_presses, type_text}; +fn text_focus_frame_id() -> egui::Id { + egui::Id::unique("shortcut-text-focus-frame") +} + +pub(crate) fn remember_text_focus(ctx: &egui::Context) { + if ctx.egui_wants_keyboard_input() { + let frame = ctx.cumulative_frame_nr_for(ctx.viewport_id()); + ctx.data_mut(|d| d.insert_temp(text_focus_frame_id(), frame)); + } +} + +fn text_focused_in_previous_frame(ctx: &egui::Context) -> bool { + let previous = ctx.cumulative_frame_nr_for(ctx.viewport_id()).checked_sub(1); + ctx.data(|d| d.get_temp::(text_focus_frame_id())) == previous +} + /// Parse "Cmd+Shift+]" into an egui shortcut. `Cmd` is Command on macOS and Ctrl elsewhere. /// `+` and `=` name one chord key ([`Key::Equals`]): `+` is Shift+`=` on many layouts, so a /// `Cmd+=` chord also answers `+` however it is typed. @@ -189,9 +205,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { presentation_keys(app, ctx, escape_pair); return; } + // Escape that cancels a ruler-origin drag or floating Tools move skips the tool and later Escape actions. + let escape_ended_gesture = crate::canvas::ruler_origin_dragging(ctx) || crate::tools_float::escape_cancelled_move(ctx); + let escape_held_by_drag = crate::canvas::dragging(ctx) || escape_ended_gesture; let typing = ctx.egui_wants_keyboard_input(); // Escape closes Edit Toolbar's drawer first (egui drops a drag from it on Escape already). - if app.ui.tools_drawer && !typing && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { + if app.ui.tools_drawer && !typing && !escape_held_by_drag && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { app.ui.tools_drawer = false; } let view = app.view_info(); @@ -208,6 +227,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { if (text || !busy) && ctx.input(|i| runs_a_command(i, k, text)) { continue; } + if k == Key::Escape && escape_ended_gesture { + remember_escape_pair(ctx, k, escape_pair); + continue; + } // A key the tool claims (Esc with a loaded place cursor) is only the tool's. let claimed = app.session.tool_claims_key(tk, view); let mods = ctx.input(|i| { @@ -232,7 +255,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, k)); } - if k == Key::Escape && !busy && !claimed { + if k == Key::Escape && !busy && !claimed && !escape_held_by_drag { if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) { let _ = app.run("object.pattern.done", json!({})); } else if app.session.active().is_some_and(|d| d.isolation.is_some()) { @@ -245,13 +268,20 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { // Escape pressed twice resets a rotated view (with the Rotate View tool, once). let _ = app.run("view.rotateReset", json!({})); escape_pair = None; + } else if matches!(app.ui.screen_mode, 1 | 2) + && !egui::Popup::is_any_open(ctx) + && !text_focused_in_previous_frame(ctx) + && ctx.input(|i| { + i.events.iter().any(|e| { + matches!(e, egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, modifiers, .. } if *modifiers == Modifiers::NONE) + }) + }) + { + let _ = app.run("view.screenMode", json!({ "mode": 0 })); } } // An Escape that ended something can start a pair too. One that closed a popup cannot. - if k == Key::Escape && escape_pair.is_some() && !egui::Popup::is_any_open(ctx) { - let now = ctx.input(|i| i.time); - ctx.data_mut(|d| d.insert_temp(escape_pair_id(), now)); - } + remember_escape_pair(ctx, k, escape_pair); if k == Key::Enter && !busy && !claimed { // Enter with a selection tool opens the Move dialog, as a double-click on its button // does. @@ -295,9 +325,13 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { if claimed_tool_keys(app, ctx, view) { return; } - // Command, panel and tool shortcuts. Keys that type a character without Cmd/Alt/Ctrl are - // handled below as text (tool shortcuts, X, D, /, `,` and `.`). - let hits = dispatch(app, ctx, |hit| !(hit.trigger.chord.is_plain() && types_text(hit.trigger.chord.key))); + // Command, panel and tool shortcuts. A bound shifted punctuation chord uses its key press; + // other text keys use the typed character below. + let hits = dispatch(app, ctx, |hit| { + let chord = hit.trigger.chord; + let shifted_punctuation = chord.mods.shift && chord.key.name().chars().all(|c| !c.is_alphanumeric()); + !(chord.is_plain() && types_text(chord.key) && !shifted_punctuation) + }); let ran = !hits.is_empty(); for c in hits { run_hit(app, ctx, &c); @@ -312,6 +346,13 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { single_key_shortcuts(app, ctx); } +fn remember_escape_pair(ctx: &egui::Context, key: Key, escape_pair: Option) { + if key == Key::Escape && escape_pair.is_some() && !egui::Popup::is_any_open(ctx) { + let now = ctx.input(|i| i.time); + ctx.data_mut(|d| d.insert_temp(escape_pair_id(), now)); + } +} + /// Dispatch unmodified text keys to command bindings or tools. 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, @@ -353,7 +394,7 @@ fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { None if hit.params.is_null() => { let _ = app.run(&hit.id, json!({})); } - None => crate::menus::invoke(app, &hit.id, hit.params.clone()), + None => crate::menus::invoke(app, &hit.id, shortcut_params(hit)), } } } @@ -361,19 +402,28 @@ fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { /// Route Alt+arrows to spacing controls when text objects are selected. fn alt_type_arrows(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { let m = ctx.input(|i| i.modifiers); - if !m.alt - || !app.session.active().is_some_and(|st| { - st.selection.guides.is_empty() - && !st.selection.objects.is_empty() - && st.selection.objects.iter().all(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Text(_)))) - }) - { + if !m.alt { return false; } + let orientation = app.session.active().and_then(|st| { + if !st.selection.guides.is_empty() || st.selection.objects.is_empty() { + return None; + } + let mut all_vertical = true; + for id in &st.selection.objects { + let node = st.doc.node(*id)?; + let vectorcraft_doc::NodeKind::Text(t) = &node.kind else { return None }; + all_vertical &= t.vertical; + } + Some(all_vertical) + }); + let Some(vertical) = orientation else { + return false; + }; let keys = [(Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right), (Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)]; for (key, tool_key) in keys { if ctx.input_mut(|i| i.consume_key(m, key)) { - if let Some((attribute, by)) = vectorcraft_tools::text::step_key(tool_key, m.shift, false) { + if let Some((attribute, by)) = vectorcraft_tools::text::step_key(tool_key, m.shift, false, vertical) { let by = if m.command { by * 5.0 } else { by }; let _ = app.run("type.step", json!({"attribute": attribute, "by": by})); } @@ -678,6 +728,25 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, accept: impl FnMut(&H let mirror = crate::shortcut_editor::current(); let dispatcher = &mut app.dispatcher; ctx.input_mut(|i| { + // Shift+Comma and Shift+Period follow their key positions when the logical character + // differs. An explicitly bound logical shifted chord takes precedence. + for e in &mut i.events { + if let egui::Event::Key { key, physical_key: Some(physical), modifiers, pressed: true, .. } = e + && modifiers.shift + && matches!(*physical, Key::Comma | Key::Period) + && *key != *physical + && let (Some(logical), Some(position)) = (craft_keymap::Key::from_egui(*key), craft_keymap::Key::from_egui(*physical)) + { + let mods = craft_keymap::Mods::from_egui(modifiers, crate::keymap::platform()); + let logical_chord = craft_keymap::Chord::new(mods, logical); + let position_chord = craft_keymap::Chord::new(mods, position); + if crate::keymap::lookup(&mirror.keymap, &logical_chord).is_some() { + *physical = *key; + } else if crate::keymap::lookup(&mirror.keymap, &position_chord).is_some() { + *key = *physical; + } + } + } // Shift+`=` types `+`: as the Plus key it also reaches a `=` chord held with Shift // (Cmd+Shift+= zooms in as Cmd+= does). for e in &mut i.events { @@ -692,6 +761,37 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, accept: impl FnMut(&H }) } +/// Params used when a shortcut invokes its command. The named panel function keys retain their +/// toggle action when their bindings are reassigned. +fn shortcut_params(c: &CommandRef) -> serde_json::Value { + let mut params = if c.params.is_null() { json!({}) } else { c.params.clone() }; + if c.id == "window.panel" + && matches!( + params.get("panel").and_then(serde_json::Value::as_str), + Some( + "brushes" + | "color" + | "layers" + | "info" + | "gradient" + | "stroke" + | "attributes" + | "graphicStyles" + | "appearance" + | "align" + | "transform" + | "pathfinder" + | "transparency" + | "symbols" + ) + ) + { + params["toggle"] = json!(true); + params["toggleGroup"] = json!(true); + } + params +} + /// Runs what a shortcut is bound to: a tool, a panel (`window.panel {panel}`) or a command. fn run_hit(app: &mut VectorcraftApp, ctx: &egui::Context, c: &CommandRef) { if let Some(tool) = c.id.strip_prefix("tool:") { @@ -701,8 +801,7 @@ fn run_hit(app: &mut VectorcraftApp, ctx: &egui::Context, c: &CommandRef) { // every artboard instead of the art. crate::panels::artboards::select_all(app); } else { - let p = if c.params.is_null() { json!({}) } else { c.params.clone() }; - crate::menus::invoke(app, &c.id, p); + crate::menus::invoke(app, &c.id, shortcut_params(c)); } } diff --git a/crates/ui-egui/src/shortcuts/presentation.rs b/crates/ui-egui/src/shortcuts/presentation.rs index c93ca762e..da746cbf5 100644 --- a/crates/ui-egui/src/shortcuts/presentation.rs +++ b/crates/ui-egui/src/shortcuts/presentation.rs @@ -34,13 +34,14 @@ pub(super) fn escape_press(ctx: &egui::Context) -> Option { } /// Does this Escape reset the view's rotation? It does when the view is rotated, no popup is open, -/// the ruler origin is not being dragged, and the press is the second of a pair (`second`) or the -/// Rotate View tool is chosen. +/// neither a ruler-origin drag nor a floating Tools move is ending, and the press is the second of +/// a pair (`second`) or the Rotate View tool is chosen. pub(super) fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context, second: bool) -> bool { app.view().is_some_and(|v| v.rotation != 0.0) && (second || app.session.tool_id() == "rotateView") && !egui::Popup::is_any_open(ctx) && !crate::canvas::ruler_origin_dragging(ctx) + && !crate::tools_float::escape_cancelled_move(ctx) } /// The keys in Presentation Mode, where nothing is edited: Escape leaves the mode, and ← and → go to diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index e97b526f9..553c57df4 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1,5 +1,67 @@ use super::*; +#[test] +fn named_panel_default_chords_toggle_their_panels() { + let keymap = crate::keymap::keymap_with(&craft_keymap::Overrides::new()); + for (panel, chord) in [ + ("brushes", "F5"), + ("color", "F6"), + ("layers", "F7"), + ("graphicStyles", "Shift+F5"), + ("appearance", "Shift+F6"), + ("align", "Shift+F7"), + ("transform", "Shift+F8"), + ("info", "Cmd+F8"), + ("gradient", "Cmd+F9"), + ("pathfinder", "Cmd+Shift+F9"), + ("stroke", "Cmd+F10"), + ("transparency", "Cmd+Shift+F10"), + ("attributes", "Cmd+F11"), + ("symbols", "Cmd+Shift+F11"), + ] { + let chord = crate::keymap::vc_chord(chord).unwrap(); + let hit = crate::keymap::lookup(&keymap, &chord).unwrap(); + assert_eq!(hit.id, "window.panel"); + assert_eq!(hit.params["panel"], panel); + assert_eq!(shortcut_params(&hit)["toggle"], true); + assert_eq!(shortcut_params(&hit)["toggleGroup"], true); + } +} + +#[test] +fn other_panel_shortcuts_keep_plain_panel_params() { + for panel in ["character", "swatches"] { + let hit = CommandRef { id: "window.panel".into(), params: json!({"panel": panel}) }; + let params = shortcut_params(&hit); + assert!(params.get("toggle").is_none(), "{panel} gained toggle"); + assert!(params.get("toggleGroup").is_none(), "{panel} gained toggleGroup"); + } +} + +#[test] +fn rebound_layers_chord_toggles_and_plain_panel_command_reveals() { + let mut overrides = craft_keymap::Overrides::new(); + crate::keymap::set_override(&mut overrides, "panel:layers", "Cmd+Alt+Shift+F7"); + let keymap = crate::keymap::keymap_with(&overrides); + let old = crate::keymap::vc_chord("F7").unwrap(); + assert!(crate::keymap::lookup(&keymap, &old).is_none(), "the old F7 is free"); + let rebound = crate::keymap::vc_chord("Cmd+Alt+Shift+F7").unwrap(); + let hit = crate::keymap::lookup(&keymap, &rebound).unwrap(); + assert_eq!(hit.id, "window.panel"); + assert_eq!(hit.params["panel"], "layers"); + + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("window.panel", json!({"panel": "layers"})).unwrap(); + let ctx = egui::Context::default(); + run_hit(&mut app, &ctx, &hit); + assert_eq!(crate::menus::checked(&app, "window.panel", &json!({"panel": "layers"})), Some(false)); + run_hit(&mut app, &ctx, &hit); + assert_eq!(crate::menus::checked(&app, "window.panel", &json!({"panel": "layers"})), Some(true)); + app.run("window.panel", json!({"panel": "layers"})).unwrap(); + assert_eq!(crate::menus::checked(&app, "window.panel", &json!({"panel": "layers"})), Some(true), "a plain command still reveals"); +} + /// One headless frame delivering `events` to the shortcut handler. fn frame(app: &mut VectorcraftApp, events: Vec) -> egui::FullOutput { let ctx = egui::Context::default(); @@ -274,6 +336,43 @@ fn cmd_alt_shift_j_opens_average_and_join() { assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("to")).as_deref(), Some("smooth"), "the choice confirmed last"); } +/// Cmd+Alt+Shift+2 locks unselected artwork, including inside an isolated group. +#[test] +fn cmd_alt_shift_2_locks_unselected_artwork() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + assert_eq!(crate::menus::shortcut_of("object.lock.unselected"), Some("Cmd+Alt+Shift+2")); + assert_eq!(crate::menus::shortcut_of("object.hide.unselected"), Some("Cmd+Alt+Shift+3")); + assert!(!crate::menus::menu_tree_named(true).iter().any(|(_, items)| format!("{items:?}").contains("object.lock.unselected"))); + let rect = |app: &mut VectorcraftApp, x: f64| { + let r = app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap(); + vectorcraft_doc::NodeId(r["id"].as_u64().unwrap()) + }; + let (a, b) = (rect(&mut app, 10.0), rect(&mut app, 50.0)); + app.session.execute("select.none", &json!({})).unwrap(); + assert!(!crate::menus::enabled(&app, "object.lock.unselected"), "nothing selected"); + let locked = |app: &VectorcraftApp| [a, b].map(|id| app.session.doc().unwrap().doc.node(id).unwrap().locked); + let two = |modifiers| egui::Event::Key { key: Key::Num2, physical_key: None, pressed: true, repeat: false, modifiers }; + app.session.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + assert!(crate::menus::enabled(&app, "object.lock.unselected"), "selection present"); + frame(&mut app, vec![two(Modifiers::COMMAND | Modifiers::ALT | Modifiers::SHIFT)]); + assert_eq!(locked(&app), [false, true]); + frame(&mut app, vec![two(Modifiers::COMMAND | Modifiers::ALT)]); + assert_eq!(locked(&app), [false, false], "Cmd+Alt+2 unlocks all"); + app.session.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + frame(&mut app, vec![two(Modifiers::COMMAND)]); + assert_eq!(locked(&app), [true, false], "Cmd+2 locks the selection"); + // The shortcut uses the isolated group as its scope. + app.session.execute("object.unlockAll", &json!({})).unwrap(); + app.session.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = app.session.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("object.isolate", &json!({"id": g})).unwrap(); + app.session.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + assert!(crate::menus::enabled(&app, "object.lock.unselected")); + frame(&mut app, vec![two(Modifiers::COMMAND | Modifiers::ALT | Modifiers::SHIFT)]); + assert_eq!(locked(&app), [false, true], "unselected member locks in isolation mode"); +} + /// Cmd+Shift+B while the Type tool edits text is Type › Bold, not Hide Bounding Box (#724). #[test] fn cmd_shift_b_while_typing_is_bold_not_the_bounding_box() { @@ -1021,18 +1120,136 @@ fn brackets_size_the_blob_brush_and_the_eraser() { assert_eq!(app.session.paint.stroke_width, 3.0); } -#[test] -fn comma_and_period_reapply_the_last_colour_and_gradient() { +fn remembered_paint_app() -> VectorcraftApp { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); app.session.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); app.session.execute("paint.setFill", &json!({"color": "#336699"})).unwrap(); - let fill = |app: &VectorcraftApp| crate::panels::current_paints(app).0; - frame(&mut app, vec![egui::Event::Text(".".into())]); - assert!(matches!(fill(&app), vectorcraft_color::Paint::Gradient(_)), "`.` applies the last gradient"); - frame(&mut app, vec![egui::Event::Text(",".into())]); - assert_eq!(fill(&app).color().unwrap().to_hex(), "#336699", "`,` applies the last colour"); + app +} + +#[test] +fn plain_and_shifted_paint_keys_run_once_on_us_and_german_like_input() { + let mut app = remembered_paint_app(); + let paint = |app: &VectorcraftApp| crate::panels::current_paints(app).0; + let press = |key, physical_key, modifiers, typed: &str| { + vec![ + egui::Event::ModifiersChanged(modifiers), + egui::Event::Key { key, physical_key: Some(physical_key), pressed: true, repeat: false, modifiers }, + egui::Event::Text(typed.into()), + ] + }; + for (key, physical_key, modifiers, typed) in [ + (Key::Period, Key::Period, Modifiers::NONE, "."), + (Key::Period, Key::Period, Modifiers::SHIFT, ">"), + (Key::Colon, Key::Period, Modifiers::SHIFT, ":"), + ] { + let before = app.session.active().unwrap().history.undo.len(); + frame(&mut app, press(key, physical_key, modifiers, typed)); + assert!(matches!(paint(&app), vectorcraft_color::Paint::Gradient(_)), "{typed} applies gradient"); + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1, "{typed} runs once"); + app.session.execute("paint.setFill", &json!({"color": "#336699"})).unwrap(); + } + for (key, physical_key, modifiers, typed) in [ + (Key::Comma, Key::Comma, Modifiers::NONE, ","), + (Key::Comma, Key::Comma, Modifiers::SHIFT, "<"), + (Key::Semicolon, Key::Comma, Modifiers::SHIFT, ";"), + ] { + app.session.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + let before = app.session.active().unwrap().history.undo.len(); + frame(&mut app, press(key, physical_key, modifiers, typed)); + assert_eq!(paint(&app).color().map(|c| c.to_hex()), Some("#336699".into()), "{typed} applies color"); + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1, "{typed} runs once"); + } + let before = app.session.active().unwrap().history.undo.len(); + for typed in [">", "<"] { + frame(&mut app, press(Key::Backslash, Key::Backslash, Modifiers::NONE, typed)); + } + assert_eq!(app.session.active().unwrap().history.undo.len(), before, "another key typing a bracket runs no paint command"); + + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 2, "end": 2})); + assert!(app.session.tool_wants_text()); + app.session.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + let paints_before = crate::panels::current_paints(&app); + assert!(matches!(paints_before.0, vectorcraft_color::Paint::Gradient(_))); + for (key, physical_key, mods, typed) in [ + (Key::Comma, Key::Comma, Modifiers::NONE, ","), + (Key::Period, Key::Period, Modifiers::NONE, "."), + (Key::Comma, Key::Comma, Modifiers::SHIFT, "<"), + (Key::Period, Key::Period, Modifiers::SHIFT, ">"), + (Key::Colon, Key::Period, Modifiers::SHIFT, ":"), + (Key::Semicolon, Key::Comma, Modifiers::SHIFT, ";"), + ] { + frame(&mut app, press(key, physical_key, mods, typed)); + assert_eq!(crate::panels::current_paints(&app), paints_before, "typing {typed} keeps fill and stroke"); + } + assert_eq!(text_and_layers(&mut app, id).0, "Hi,.<>:;"); +} + +#[test] +fn explicit_logical_shifted_binding_wins_over_position_alias() { + let mut app = remembered_paint_app(); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "paint.lastGradient", "Shift+;"); + let before = app.session.active().unwrap().history.undo.len(); + frame( + &mut app, + vec![ + egui::Event::ModifiersChanged(Modifiers::SHIFT), + egui::Event::Key { key: Key::Semicolon, physical_key: Some(Key::Comma), pressed: true, repeat: false, modifiers: Modifiers::SHIFT }, + egui::Event::Text(";".into()), + ], + ); + assert!(matches!(crate::panels::current_paints(&app).0, vectorcraft_color::Paint::Gradient(_))); + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1); +} + +#[test] +fn user_shifted_punctuation_binding_runs_once() { + let mut app = remembered_paint_app(); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "paint.lastGradient", "Shift+;"); + let undo_before = app.session.active().unwrap().history.undo.len(); + let shifted = Modifiers::SHIFT; + frame( + &mut app, + vec![ + egui::Event::ModifiersChanged(shifted), + egui::Event::Key { key: Key::Semicolon, physical_key: Some(Key::Semicolon), pressed: true, repeat: false, modifiers: shifted }, + egui::Event::Text(":".into()), + ], + ); + assert!(matches!(crate::panels::current_paints(&app).0, vectorcraft_color::Paint::Gradient(_))); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo_before + 1); +} + +#[test] +fn saved_plain_paint_override_runs_its_new_command() { + let mut app = remembered_paint_app(); + let saved = serde_json::json!({"paint.lastGradient": ",", "paint.lastColor": "."}); + app.ui.shortcut_overrides = crate::keymap::stored::from_value(saved); + let press = |key, typed: &str| { + vec![ + egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE }, + egui::Event::Text(typed.into()), + ] + }; + frame(&mut app, press(Key::Comma, ",")); + assert!(matches!(crate::panels::current_paints(&app).0, vectorcraft_color::Paint::Gradient(_))); + frame(&mut app, press(Key::Period, ".")); + assert_eq!(crate::panels::current_paints(&app).0.color().map(|c| c.to_hex()), Some("#336699".into())); + crate::shortcut_editor::reset_one(&mut app.ui.shortcut_overrides, "paint.lastGradient"); + frame( + &mut app, + vec![ + egui::Event::ModifiersChanged(Modifiers::SHIFT), + egui::Event::Key { key: Key::Period, physical_key: Some(Key::Period), pressed: true, repeat: false, modifiers: Modifiers::SHIFT }, + egui::Event::Text(">".into()), + ], + ); + assert!(matches!(crate::panels::current_paints(&app).0, vectorcraft_color::Paint::Gradient(_)), "reset restores the extra default"); } #[test] @@ -1094,6 +1311,104 @@ fn cmd_alt_shift_period_and_comma_step_the_font_size_by_five() { assert_eq!(sizes(&mut app), [(json!("One "), json!(14.0)), (json!("Two"), json!(24.0))]); } +/// Spacing arrow keys change tracking, leading, and baseline shift for a selected vertical text object. +#[test] +fn alt_arrows_rotate_for_selected_vertical_type() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let vertical = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "One Two", "size": 20, "vertical": true})).unwrap()["id"] + .as_u64() + .unwrap(); + app.select_tool("selection"); + app.session.execute("select.set", &json!({"ids": [vertical]})).unwrap(); + let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let cases = [ + (Key::ArrowDown, Modifiers::ALT, "tracking", 20.0), + (Key::ArrowUp, Modifiers::ALT, "tracking", 0.0), + (Key::ArrowLeft, Modifiers::ALT, "leading", 26.0), + (Key::ArrowRight, Modifiers::ALT, "leading", 24.0), + (Key::ArrowRight, Modifiers::ALT | Modifiers::SHIFT, "baseline_shift", 2.0), + (Key::ArrowLeft, Modifiers::ALT | Modifiers::SHIFT, "baseline_shift", 0.0), + (Key::ArrowDown, Modifiers::ALT | Modifiers::COMMAND, "tracking", 100.0), + (Key::ArrowUp, Modifiers::ALT | Modifiers::COMMAND, "tracking", 0.0), + ]; + for (key, modifiers, field, value) in cases { + frame(&mut app, vec![egui::Event::ModifiersChanged(modifiers), press(key, modifiers)]); + let range = app.session.execute("text.getRange", &json!({"id": vertical})).unwrap(); + assert_eq!(range["runs"][0]["style"][field], json!(value), "{key:?}"); + assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(vertical)]); + } +} + +#[test] +fn mixed_type_objects_take_horizontal_spacing_chords_in_both_orders() { + let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + for vertical_first in [true, false] { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let vertical = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "One", "size": 20, "vertical": true})).unwrap()["id"] + .as_u64() + .unwrap(); + let horizontal = app.session.execute("text.create", &json!({"x": 80, "y": 50, "text": "Two", "size": 20})).unwrap()["id"].as_u64().unwrap(); + let ids = if vertical_first { [vertical, horizontal] } else { [horizontal, vertical] }; + app.select_tool("type"); + app.session.execute("select.set", &json!({"ids": ids})).unwrap(); + let before = app.session.active().unwrap().history.undo.len(); + let original = ids.map(|id| app.session.execute("text.getRange", &json!({"id": id})).unwrap()); + frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::ALT), press(Key::ArrowDown, Modifiers::ALT)]); + for id in ids { + let range = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + assert_eq!(range["runs"][0]["style"]["leading"], json!(26.0)); + } + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1); + assert_eq!(app.session.active().unwrap().selection.objects.len(), 2); + app.session.execute("edit.undo", &json!({})).unwrap(); + for (id, expected) in ids.into_iter().zip(original) { + let range = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + assert_eq!(range, expected); + } + app.session.execute("edit.redo", &json!({})).unwrap(); + for id in ids { + let range = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + assert_eq!(range["runs"][0]["style"]["leading"], json!(26.0)); + } + frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::ALT), press(Key::ArrowRight, Modifiers::ALT)]); + for id in ids { + let range = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + assert_eq!(range["runs"][0]["style"]["tracking"], json!(20.0)); + } + assert_eq!(app.session.active().unwrap().selection.objects.len(), 2); + } +} + +#[test] +fn selected_vertical_area_and_path_type_use_vertical_spacing_chords() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + let area_frame = app.session.execute("path.create", &json!({"d": "M10 10 L100 10 L100 100 L10 100 Z"})).unwrap()["id"].as_u64().unwrap(); + let path = app.session.execute("path.create", &json!({"d": "M150 10 L250 10"})).unwrap()["id"].as_u64().unwrap(); + let area = + app.session.execute("text.createInPath", &json!({"path": area_frame, "mode": "area", "text": "Area", "vertical": true, "size": 20})).unwrap() + ["id"] + .as_u64() + .unwrap(); + let on_path = app + .session + .execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "Path", "vertical": true, "size": 20})) + .unwrap()["id"] + .as_u64() + .unwrap(); + app.select_tool("selection"); + app.session.execute("select.set", &json!({"ids": [area, on_path]})).unwrap(); + let modifiers = Modifiers::ALT; + let press = egui::Event::Key { key: Key::ArrowDown, physical_key: None, pressed: true, repeat: false, modifiers }; + frame(&mut app, vec![egui::Event::ModifiersChanged(modifiers), press]); + for id in [area, on_path] { + let range = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + assert_eq!(range["runs"][0]["style"]["tracking"], json!(20.0)); + } +} + /// #3786: Alt+arrows step a selected text object's spacing instead of copying and nudging it. #[test] fn alt_arrows_step_selected_type_object() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 72a8be32f..c85f6e668 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -382,6 +382,9 @@ pub struct UiState { pub docking: Option>, #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub docking_hidden: std::collections::BTreeMap>, + /// Tabs closed together by panel shortcuts, kept with their workspace return locations. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub docking_hidden_groups: Vec>, /// Panels dropped on the icon column, in older files (read only, like [`Self::floating_panels`]). #[serde(default, skip_serializing)] pub docking_icons: std::collections::BTreeSet, @@ -838,6 +841,7 @@ impl Default for UiState { floating_panels: vec![], docking: None, docking_hidden: Default::default(), + docking_hidden_groups: Vec::new(), docking_icons: Default::default(), docking_collapsed: Default::default(), docking_generation: 0, diff --git a/crates/ui-egui/src/tests/screenmode.rs b/crates/ui-egui/src/tests/screenmode.rs index 0c7f2d2e8..6011fa334 100644 --- a/crates/ui-egui/src/tests/screenmode.rs +++ b/crates/ui-egui/src/tests/screenmode.rs @@ -68,6 +68,497 @@ fn snapshot(app: &VectorcraftApp) -> impl Fn(&VectorcraftApp) -> bool + use<> { } } +fn in_screen_mode(ctx: &egui::Context, mode: u8) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 90, "y": 40, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.select_tool("selection"); + frame(&mut app, ctx, vec![]); + app.run("view.screenMode", json!({"mode": mode})).unwrap(); + frame(&mut app, ctx, vec![]); + app +} + +fn begin_tools_move(app: &mut VectorcraftApp, ctx: &egui::Context) -> (Option<[f32; 2]>, egui::Pos2) { + app.run("window.panel.float", json!({"panel": "tools", "x": 120, "y": 140})).unwrap(); + frame(app, ctx, vec![]); + frame(app, ctx, vec![]); + let original = app.ui.toolbar_pos; + let area = ctx.memory(|m| m.area_rect(egui::Id::unique("toolbar-floating"))).expect("the floating Tools area"); + let title = area.min + egui::vec2(20.0, 17.0); + let to = title + egui::vec2(100.0, 80.0); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![egui::Event::PointerMoved(title), button(title, true)]); + for step in 1..=6 { + frame(app, ctx, vec![egui::Event::PointerMoved(title + (to - title) * (step as f32 / 6.0))]); + } + assert_ne!(app.ui.toolbar_pos, original, "the Tools panel moved"); + (original, to) +} + +fn begin_ruler_origin_drag(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Pos2 { + app.ui.view.rulers = true; + frame(app, ctx, vec![]); + let corner = ctx.read_response(egui::Id::unique("ruler-corner")).expect("the rulers' corner").rect.center(); + let to = app.canvas_rect.unwrap().center(); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![egui::Event::PointerMoved(corner)]); + frame(app, ctx, vec![button(corner, true)]); + frame(app, ctx, vec![egui::Event::PointerMoved(to)]); + assert!(crate::canvas::ruler_origin_dragging(ctx)); + to +} + +fn release_drag(app: &mut VectorcraftApp, ctx: &egui::Context, to: egui::Pos2) { + frame( + app, + ctx, + vec![egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }], + ); +} + +fn start_escape_action(app: &mut VectorcraftApp, ctx: &egui::Context, action: &str) { + match action { + "drawer" => app.ui.tools_drawer = true, + "pattern" => { + app.run("object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); + } + "isolation" => { + app.run("shape.rectangle", json!({"x": 130, "y": 40, "width": 20, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("object.group", json!({})).unwrap(); + app.run("object.isolate", json!({})).unwrap(); + } + "global" => { + app.run("shape.rectangle", json!({"x": 130, "y": 40, "width": 20, "height": 20})).unwrap(); + app.run("select.globalEdit", json!({})).unwrap(); + } + "flyout" => { + app.ui.flyout = Some(0); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("flyout-tools"), vec!["selection".to_string(), "directSelection".to_string()])); + } + _ => panic!("unknown Escape action"), + } + assert!(escape_action_active(app, action), "{action} starts"); +} + +fn escape_action_active(app: &VectorcraftApp, action: &str) -> bool { + match action { + "drawer" => app.ui.tools_drawer, + "pattern" => app.session.active().unwrap().doc.pattern_edit.is_some(), + "isolation" => app.session.active().unwrap().isolation.is_some(), + "global" => app.session.active().unwrap().global_edit.is_some(), + "flyout" => app.ui.flyout.is_some(), + _ => false, + } +} + +#[test] +fn escape_leaves_both_full_screen_modes_without_editing() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + let unchanged = snapshot(&app); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "mode {mode}"); + assert!(unchanged(&app), "mode {mode}: document, selection and undo history"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "Escape in Normal mode"); + } +} + +#[test] +fn modified_escape_keeps_full_screen_mode() { + for modifiers in [egui::Modifiers::SHIFT, egui::Modifiers::CTRL, egui::Modifiers::COMMAND, egui::Modifiers::ALT] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, 2); + let events = + [true, false].map(|pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); + frame(&mut app, &ctx, events); + assert_eq!(app.ui.screen_mode, 2, "{modifiers:?}"); + } +} + +#[test] +fn drawer_and_isolation_take_escape_before_full_screen() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.ui.tools_drawer = true; + frame(&mut app, &ctx, escape()); + assert!(!app.ui.tools_drawer); + assert_eq!(app.ui.screen_mode, mode, "drawer"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after drawer"); + + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.run("shape.rectangle", json!({"x": 130, "y": 40, "width": 20, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("object.group", json!({})).unwrap(); + app.run("object.isolate", json!({})).unwrap(); + assert!(app.session.active().unwrap().isolation.is_some()); + frame(&mut app, &ctx, escape()); + assert!(app.session.active().unwrap().isolation.is_none()); + assert_eq!(app.ui.screen_mode, mode, "isolation"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after isolation"); + } +} + +#[test] +fn a_tool_drag_takes_escape_before_full_screen_exits() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, 2); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 50.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 120.0, 70.0), view).unwrap(); + assert!(app.session.tool_busy()); + frame(&mut app, &ctx, escape()); + assert!(!app.session.tool_busy()); + assert_eq!(app.ui.screen_mode, 2); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0); +} + +fn navigation_drag_keeps_screen_mode(kind: &str) { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + let button = match kind { + "hand" => { + app.select_tool("hand"); + egui::PointerButton::Primary + } + "middle" => egui::PointerButton::Middle, + "zoom_box" => { + app.select_tool("zoom"); + app.session.prefs.animated_zoom = false; + egui::PointerButton::Primary + } + _ => panic!("unknown navigation drag"), + }; + frame(&mut app, &ctx, vec![]); + let start = app.canvas_rect.unwrap().center(); + let end = start + egui::vec2(35.0, 20.0); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), pointer(start, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(end)]); + assert!(crate::canvas::dragging(&ctx), "{kind} drag began in mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, mode, "{kind} drag holds Escape in mode {mode}"); + frame(&mut app, &ctx, vec![pointer(end, false)]); + assert!(!crate::canvas::dragging(&ctx), "{kind} drag ended in mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "Escape after {kind} drag in mode {mode}"); + } +} + +#[test] +fn a_hand_drag_keeps_both_full_screen_modes_until_release() { + navigation_drag_keeps_screen_mode("hand"); +} + +#[test] +fn a_middle_button_pan_keeps_both_full_screen_modes_until_release() { + navigation_drag_keeps_screen_mode("middle"); +} + +#[test] +fn a_zoom_box_drag_keeps_both_full_screen_modes_until_release() { + navigation_drag_keeps_screen_mode("zoom_box"); +} + +#[test] +fn a_ruler_origin_drag_takes_escape_before_full_screen_exits() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.ui.view.rulers = true; + frame(&mut app, &ctx, vec![]); + let corner = ctx.read_response(egui::Id::unique("ruler-corner")).expect("the rulers' corner").rect.center(); + let to = app.canvas_rect.unwrap().center(); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(corner)]); + frame(&mut app, &ctx, vec![button(corner, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]); + assert!(crate::canvas::ruler_origin_dragging(&ctx), "mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, mode, "Escape cancels the drag in mode {mode}"); + frame(&mut app, &ctx, vec![button(to, false)]); + assert!(!crate::canvas::ruler_origin_dragging(&ctx), "mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "Escape after the drag in mode {mode}"); + } +} + +#[test] +fn a_floating_tools_move_takes_escape_before_full_screen_exits() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.run("window.panel.float", json!({"panel": "tools", "x": 120, "y": 140})).unwrap(); + frame(&mut app, &ctx, vec![]); + frame(&mut app, &ctx, vec![]); + let original = app.ui.toolbar_pos; + let [x, y] = original.expect("the Tools panel floats"); + let area = ctx.memory(|m| m.area_rect(egui::Id::unique("toolbar-floating"))).expect("the floating Tools area"); + let title = area.min + egui::vec2(20.0, 17.0); + let to = title + egui::vec2(100.0, 80.0); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(title), button(title, true)]); + for step in 1..=6 { + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(title + (to - title) * (step as f32 / 6.0))]); + } + assert_ne!(app.ui.toolbar_pos, original, "the Tools panel moved in mode {mode}: saved {x} {y}, area {area:?}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, mode, "Escape cancels the Tools move in mode {mode}"); + assert_eq!(app.ui.toolbar_pos, original, "Escape restores the Tools panel's position"); + frame(&mut app, &ctx, vec![button(to, false)]); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "Escape after the move in mode {mode}"); + } +} + +#[test] +fn cancelling_tools_move_keeps_rotation_in_both_full_screen_modes() { + for mode in [1, 2] { + for tool in ["rotateView", "selection"] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.select_tool(tool); + app.run("view.rotate", json!({"angle": 30})).unwrap(); + if tool == "selection" { + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "first Escape leaves mode {mode}"); + assert_eq!(app.view().unwrap().rotation, 30.0); + } + let (original, to) = begin_tools_move(&mut app, &ctx); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.toolbar_pos, original, "{tool} in mode {mode}"); + assert_eq!(app.view().unwrap().rotation, 30.0, "{tool} in mode {mode}"); + release_drag(&mut app, &ctx, to); + frame(&mut app, &ctx, escape()); + assert_eq!(app.view().unwrap().rotation, 0.0, "later Escape resets rotation"); + } + } +} + +#[test] +fn cancelling_tools_move_keeps_the_next_escape_action() { + for mode in [0, 1, 2] { + for action in ["drawer", "pattern", "isolation", "global", "flyout"] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + let (original, to) = begin_tools_move(&mut app, &ctx); + start_escape_action(&mut app, &ctx, action); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.toolbar_pos, original, "move cancelled in mode {mode}, {action}"); + assert!(escape_action_active(&app, action), "Escape keeps {action} in mode {mode}"); + assert_eq!(app.ui.screen_mode, mode, "mode {mode}, {action}"); + release_drag(&mut app, &ctx, to); + frame(&mut app, &ctx, escape()); + assert!(!escape_action_active(&app, action), "later Escape ends {action} in mode {mode}"); + } + } +} + +#[test] +fn cancelling_tools_move_keeps_the_tools_escape() { + for mode in [0, 1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.select_tool("intertwine"); + let (original, to) = begin_tools_move(&mut app, &ctx); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.toolbar_pos, original, "move canceled in mode {mode}"); + assert_eq!(app.session.tool_id(), "intertwine", "move Escape keeps the tool in mode {mode}"); + release_drag(&mut app, &ctx, to); + frame(&mut app, &ctx, escape()); + assert_eq!(app.session.tool_id(), "selection", "later Escape reaches the tool in mode {mode}"); + } +} + +#[test] +fn cancelling_ruler_origin_drag_keeps_the_next_escape_action() { + for mode in [0, 1, 2] { + for action in ["drawer", "pattern", "isolation", "global", "flyout"] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + let to = begin_ruler_origin_drag(&mut app, &ctx); + start_escape_action(&mut app, &ctx, action); + frame(&mut app, &ctx, escape()); + assert!(escape_action_active(&app, action), "Escape keeps {action} in mode {mode}"); + assert_eq!(app.ui.screen_mode, mode, "mode {mode}, {action}"); + release_drag(&mut app, &ctx, to); + assert!(!crate::canvas::ruler_origin_dragging(&ctx)); + frame(&mut app, &ctx, escape()); + assert!(!escape_action_active(&app, action), "later Escape ends {action} in mode {mode}"); + } + } +} + +#[test] +fn cancelling_ruler_origin_drag_keeps_the_tools_escape() { + for mode in [0, 1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.select_tool("intertwine"); + let to = begin_ruler_origin_drag(&mut app, &ctx); + frame(&mut app, &ctx, escape()); + assert_eq!(app.session.tool_id(), "intertwine", "drag Escape keeps the tool in mode {mode}"); + release_drag(&mut app, &ctx, to); + assert!(!crate::canvas::ruler_origin_dragging(&ctx)); + frame(&mut app, &ctx, escape()); + assert_eq!(app.session.tool_id(), "selection", "later Escape reaches the tool in mode {mode}"); + } +} + +#[test] +fn a_popup_takes_escape_before_full_screen_exits() { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, 2); + let pos = app.canvas_rect.unwrap().center(); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos), button(true)]); + frame(&mut app, &ctx, vec![button(false)]); + frame(&mut app, &ctx, vec![]); + assert!(egui::Popup::is_any_open(&ctx)); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 2); + assert!(!egui::Popup::is_any_open(&ctx)); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0); +} + +#[test] +fn a_rotated_view_resets_before_full_screen_exits() { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, 2); + app.select_tool("rotateView"); + app.run("view.rotate", json!({"angle": 30})).unwrap(); + frame(&mut app, &ctx, escape()); + assert_eq!(app.view().unwrap().rotation, 0.0); + assert_eq!(app.ui.screen_mode, 2); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0); +} + +#[test] +fn a_dialog_takes_escape_before_full_screen_exits() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.ui.dialog = Some(crate::state::Dialog::new("rectangle", json!({"width": 20, "height": 20}))); + frame(&mut app, &ctx, vec![]); + frame(&mut app, &ctx, escape()); + assert!(app.ui.dialog.is_none(), "mode {mode}"); + assert_eq!(app.ui.screen_mode, mode, "mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after the dialog in mode {mode}"); + } +} + +#[test] +fn a_focused_field_keeps_full_screen_mode() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + let id = egui::Id::unique("screen-mode-field"); + let mut value = String::new(); + let mut field_frame = |app: &mut VectorcraftApp, events, focus| { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + let response = ui.add(egui::TextEdit::singleline(&mut value).id(id)); + if focus { + response.request_focus(); + } + app.ui(ui); + }); + out.textures_delta.clear(); + }; + field_frame(&mut app, vec![], true); + assert!(ctx.egui_wants_keyboard_input()); + field_frame(&mut app, escape(), false); + assert_eq!(app.ui.screen_mode, mode, "focused field in mode {mode}"); + } +} + +#[test] +fn a_claimed_tool_key_keeps_full_screen_mode() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.select_tool("intertwine"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, mode, "Intertwine claims Escape in mode {mode}"); + app.select_tool("selection"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after changing tools in mode {mode}"); + } +} + +#[test] +fn pattern_edit_and_global_edit_take_escape_before_full_screen() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.run("object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); + assert!(app.session.active().unwrap().doc.pattern_edit.is_some()); + frame(&mut app, &ctx, escape()); + assert!(app.session.active().unwrap().doc.pattern_edit.is_none()); + assert_eq!(app.ui.screen_mode, mode, "pattern edit in mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after pattern edit in mode {mode}"); + + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.run("shape.rectangle", json!({"x": 130, "y": 40, "width": 20, "height": 20})).unwrap(); + app.run("select.globalEdit", json!({})).unwrap(); + assert!(app.session.active().unwrap().global_edit.is_some()); + frame(&mut app, &ctx, escape()); + assert!(app.session.active().unwrap().global_edit.is_none()); + assert_eq!(app.ui.screen_mode, mode, "Global Edit in mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after Global Edit in mode {mode}"); + } +} + +#[test] +fn a_tool_flyout_takes_escape_before_full_screen() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.ui.flyout = Some(0); + frame(&mut app, &ctx, escape()); + assert!(app.ui.flyout.is_none()); + assert_eq!(app.ui.screen_mode, mode, "flyout in mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after flyout in mode {mode}"); + } +} + +#[test] +fn a_rotation_pair_exits_full_screen_then_resets_with_selection_tool() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.run("view.rotate", json!({"angle": 30})).unwrap(); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "first Escape in mode {mode}"); + assert_eq!(app.view().unwrap().rotation, 30.0); + frame(&mut app, &ctx, escape()); + assert_eq!(app.view().unwrap().rotation, 0.0, "second Escape after mode {mode}"); + } +} + #[test] fn escape_leaves_presentation_mode() { let mut app = VectorcraftApp::new(Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index d58102761..02efbfd4c 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -486,7 +486,7 @@ fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, cols: usiz ui.add_space((ui.available_width() - 3.0 * chip - 2.0 * gap) / 2.0); ui.spacing_mut().item_spacing.x = gap; let mut clicked = None; - for (tip, cmd) in [("Color (,)", "paint.lastColor"), ("Gradient (.)", "paint.lastGradient"), ("None (/)", "paint.none")] { + for (tip, cmd) in [("Color (, or <)", "paint.lastColor"), ("Gradient (. or >)", "paint.lastGradient"), ("None (/)", "paint.none")] { let (r, resp) = ui.allocate_exact_size(vec2(chip, chip), Sense::click()); match cmd { "paint.lastColor" => widgets::paint_chip(ui, r, &Paint::solid(app.session.last_solid)), diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs index 6c1c1b321..e8e3f4977 100644 --- a/crates/ui-egui/src/toolbar/tests.rs +++ b/crates/ui-egui/src/toolbar/tests.rs @@ -109,7 +109,7 @@ fn screen_readers_name_the_tool_buttons_and_the_active_tool() { let name = |id| tool_info(id).map(tip).unwrap_or_default(); assert_eq!(role_and_state(&tree, &name("pen")), (Role::Button, Some(Toggled::True))); assert_eq!(role_and_state(&tree, &name("selection")), (Role::Button, Some(Toggled::False))); - for label in ["Toggle single/double column", "Drag to float the toolbar", "Color (,)", "Gradient (.)", "None (/)"] { + for label in ["Toggle single/double column", "Drag to float the toolbar", "Color (, or <)", "Gradient (. or >)", "None (/)"] { assert_eq!(role_and_state(&tree, label), (Role::Button, None), "{label}"); } } diff --git a/crates/ui-egui/src/tools_float.rs b/crates/ui-egui/src/tools_float.rs index 71101b710..25d86b69f 100644 --- a/crates/ui-egui/src/tools_float.rs +++ b/crates/ui-egui/src/tools_float.rs @@ -8,7 +8,7 @@ //! agents. Its position is kept with the preferences and in user workspaces, and a position saved //! on a bigger window is clamped into this one when drawn. -use egui::{CornerRadius, Id, Pos2, Rect, Stroke, Vec2, pos2, vec2}; +use egui::{CornerRadius, Id, Key, Pos2, Rect, Stroke, Vec2, pos2, vec2}; use serde_json::{Value, json}; use crate::VectorcraftApp; @@ -33,6 +33,14 @@ fn move_id() -> Id { Id::unique("tools-float-move") } +fn escape_cancel_id() -> Id { + Id::unique("tools-float-escape-cancel") +} + +pub(crate) fn escape_cancelled_move(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::(escape_cancel_id())) == Some(ctx.cumulative_frame_nr_for(ctx.viewport_id())) +} + /// The strip along the window's left edge where the Tools panel docks, as last laid out. pub(crate) fn tools_zone_id() -> Id { Id::unique("tools-float-zone") @@ -50,6 +58,13 @@ fn gesture_allowed(ctx: &egui::Context) -> bool { ctx.input(|input| input.focused && !input.key_pressed(egui::Key::Escape)) } +/// Cancel a move before shortcuts route the same Escape press. +pub(crate) fn cancel_move_on_escape(app: &mut VectorcraftApp, ctx: &egui::Context) { + if ctx.input(|i| i.key_pressed(Key::Escape)) { + track(app, ctx); + } +} + fn start(app: &VectorcraftApp, ctx: &egui::Context, grab: Vec2) { let Some(from) = ctx.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())) else { return }; ctx.data_mut(|d| d.insert_temp(move_id(), Move { grab, from, original: app.ui.toolbar_pos })); @@ -94,6 +109,10 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { if !gesture_allowed(ctx) { app.ui.toolbar_pos = m.original; ctx.data_mut(|d| d.remove::(move_id())); + if ctx.input(|i| i.key_pressed(Key::Escape)) { + let frame = ctx.cumulative_frame_nr_for(ctx.viewport_id()); + ctx.data_mut(|d| d.insert_temp(escape_cancel_id(), frame)); + } return; } let at = ctx.input(|i| i.pointer.interact_pos()); diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs index a67bc690c..a35356378 100644 --- a/crates/ui-egui/src/workspaces.rs +++ b/crates/ui-egui/src/workspaces.rs @@ -43,6 +43,9 @@ pub struct Workspace { pub docking: Option>, #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub docking_hidden: std::collections::BTreeMap>, + /// Tabs closed together by a panel shortcut. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub docking_hidden_groups: Vec>, /// Panels dropped on the icon column, in older workspaces (read only). #[serde(default, skip_serializing)] pub docking_icons: std::collections::BTreeSet, @@ -184,6 +187,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace { floating_panels: Vec::new(), docking: ui.docking.clone(), docking_hidden: ui.docking_hidden.clone(), + docking_hidden_groups: ui.docking_hidden_groups.clone(), docking_icons: Default::default(), docking_collapsed: Default::default(), toolbar_pos: ui.toolbar_pos, @@ -209,6 +213,7 @@ pub fn apply(ui: &mut UiState, w: &Workspace) { ui.docking_generation = ui.docking_generation.wrapping_add(1); ui.docking = w.docking.clone().filter(crate::panel_docking::valid); ui.docking_hidden = w.docking_hidden.clone(); + ui.docking_hidden_groups = w.docking_hidden_groups.clone(); ui.toolbar_pos = w.toolbar_pos; ui.dock_width = w.dock_width; crate::state::sanitize_toolbar_pos(&mut ui.toolbar_pos); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index af662106c..dc23c77b7 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai` import: pages cropped to the art in `UserUnit` units, and their copies' type) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (PDF import: knockout and isolated groups keep their flags after a group drawn straight onto its parent) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -132,11 +132,24 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | PDF import (and a `.ai`'s PDF part): a transparency group's knockout and isolate flags (`/K`, `/I`; ISO 32000-1, 11.4.6.3) come from the interpreter as it pushes the group. They were read from the file in order and matched to the groups pushed, but a group that adds nothing (full opacity, Normal, no mask, neither flag) is drawn straight onto its parent without being pushed, so every group after one lost its own flags and took another's; and a page drawing form groups more than 10,000 times failed to import (the list of their flags was capped), which it no longer does. A blend's steps written as a knockout group of falling opacity drew as solid dark shapes: a user's `.ai` with 143 such bubbles now draws them as light rings, as poppler does (its bubble area 4.9% → 1.2% of pixels off from poppler's render); 16 other users' PDFs with transparency groups (44 pages) draw the same. Test `tests::import_fidelity::knockout_groups_after_a_group_drawn_straight_keep_their_flag`; scores unchanged | +| 2026-10-30 | minor | `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout; blends are written with knockout on) in the state block after its record, as a group takes the one after its end; it was left for the next object, so a blend's steps of falling opacity drew over each other instead of knocking each other out, and every such blend came in too dark (a user's file has 143, its bubbles came in as dark rings). Both a live blend and drawn steps kept as a group take it. Test `import::ai::tests::a_blends_transparency_follows_its_record`; checked locally against that file: it comes 1.3% → 0.6% nearer its page, though a mesh seen through a mesh-shaped opacity mask still keeps it just over the limit. When a blend is the layer's last object and the layer's own state block (`7 () XW`, then `LB`) follows, the state is the layer's and the blend keeps its own, as for meshes (#4748); scores unchanged | +| 2026-10-31 | minor | PDF (and `.ai`) with Illustrator editing data saved with only some of its artboards: its pages are those artboards, while the editing data keeps them all, and the layers were compared with the first page on the first artboard, so a page of the second artboard came in as the PDF part, without its layers. With fewer pages than artboards, the first page is now taken to be the artboard of its size whose art draws most like it (up to 16 weighed), and the pages' type goes to that artboard and the ones after it. All artboards come in, as before. Test `import::tests_native::a_pdf_of_its_second_artboard_is_compared_on_that_artboard`; scores unchanged | +| 2026-10-30 | minor | `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout; blends are written with knockout on) in the state block after its record, as a group takes the one after its end; it was left for the next object, so a blend's steps of falling opacity drew over each other instead of knocking each other out, and every such blend came in too dark (a user's file has 143, its bubbles came in as dark rings). Both a live blend and drawn steps kept as a group take it. Test `import::ai::tests::a_blends_transparency_follows_its_record`; checked locally against that file: it comes 1.3% → 0.6% nearer its page, though a mesh seen through a mesh-shaped opacity mask still keeps it just over the limit; scores unchanged | +| 2026-10-30 | minor | `.ai`/EPS editing data: a gradient mesh with an opacity mask takes the transparency (`Xy`) written after the mask's dictionary, as one without a mask takes the one right after its `/End X!`; the operators of the mask's art (on `%_` lines) hid it, so the state was read as the next object's and each masked mesh in a run drew with the blend mode of the one before it (a Multiply shading drawn Normal, an Overlay tint drawn Multiply over the art below). Test `import::ai::tests::a_masked_gradient_mesh_takes_the_transparency_written_after_its_mask`; checked locally against a user's files: one opens from its layers 0.1% from its page (it was 0.6%, just inside the limit), another comes 1.6% → 1.3% nearer; scores unchanged | +| 2026-10-30 | minor | `.ai`/EPS editing data: a layer's own state block (`Xy`, `Xd`, `7 () XW` before `LB`) can have the stroke style (`J`, `j`, `w`, `M`, `d`, `XR`) between its `Xy` and `Xd`; read as the art moving on, it lost the layer's opacity, so a layer at 17% drew opaque over the art under it and the file came in as its page. When the layer's last object is a gradient mesh, the mesh keeps its own state through the stroke style too (#4748). An object's own state block (`6 () XW`) still isn't the layer's. Of 408 users' files whose editing data was checked, 3 have such a block: an EPS that now opens from its layers 0.5% from its page (it was 18.8%), one whose layer now draws at 85% as its page does, and one whose layers are all at 100%; the others draw as before. Test `import::ai::tests::a_layers_state_keeps_through_the_stroke_style_written_in_it`; scores unchanged | +| 2026-10-30 | minor | EPS and `.ai` import: editing data whose type is in the legacy format (`To` … `TO`, as an EPS saved for Illustrator 10 keeps it, with no `/AI11Text` for the page's type to go in) is read through its layers when the page has text: its text objects are the layers' own, and the comparison with the page says whether the layers draw it. Before, such a file came in as its page ("its layers have no text objects to put it in"). Type the editing data keeps only as outlines (what the legacy format can't hold) stays outlines, with a note counting the page's text objects that are; a file whose layers have no text object at all still comes in as its page. Test `import::tests_native::type_in_the_legacy_format_is_the_layers_own_and_the_page_has_text`; checked locally against a user's files (of 6 such EPS that came in as their page, 3 now come in through their layers, within the comparison's limits; 1 is still refused by the comparison for art that isn't type, and 2 have their type only as outlines; no change in the other 284 EPS with editing data and 117 `.ai` files checked); scores unchanged | +| 2026-10-30 | minor | `.ai`/EPS editing data: an `Xy` right after a gradient mesh's `/End X!` is the mesh's only when its own `6 () XW` follows; when the layer's `LB` follows (directly or after the layer's `Xd` and `7 () XW`) it is the layer's, as before #4744, and the mesh keeps the state it had. Test `import::ai::tests::a_layer_keeps_its_own_transparency`; scores unchanged | +| 2026-10-30 | minor | `.ai`/EPS editing data: type on a path is spaced round curves as the file's own layout spaces it, closer round the outside and further apart round the inside, though its frame keeps no spacing: the Type on a Path Options › Spacing that does that is fitted from the layout's glyph positions, where it came in at the font's advances and ran on past where the page ends it. In 8 files of a user's set with type round curves (19 stories), the fitted spacing is a fifth of the line's ascent outside a curve (17 stories) and half of it inside (2), and the 4 files whose pages were measured space their glyphs as their layouts do. Own-fixture tests in `import::tests_native` and `import::ate`; scores unchanged | +| 2026-10-30 | minor | `.ai` import: a placed (linked) file whose data gives two placements is put at the corner of its box when that draws closer to the page and the layers still match it, not only when the first placement fails the comparison: a small logo in the wrong place differed too little to fail, and stayed there. Test `import::tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`; scores unchanged | +| 2026-10-30 | minor | `.ai` import: a file whose layers hold placed (linked) PDFs and no text object comes in from its layers when all of its page's type lies inside the placed files (at either of their placements): the PDF part draws each placed file, type and all, where the layers show its preview, so that type needs no text object. Such a file came in as its PDF part, saying its layers had no text objects for its page's text; type outside the placed files still needs one. Own-fixture test in `import::tests_native`; scores unchanged | +| 2026-10-30 | minor | `.ai`/EPS editing data: a gradient mesh takes the transparency (`Xy`) written right after its `/End X!`, before its `Xd` and `XW`, as a group takes the one after its `U`; it was read as the next object's, so a Multiply shadow mesh painted opaque white over the art under it, where the file's page shows the shadow. A user's file now draws such a mesh as its page does. Own-fixture test in `import::ai::tests`; scores unchanged | +| 2026-10-30 | minor | `.ai`/EPS editing data: a gradient mesh corner whose handle weights are 0 (one or both; the others are 1) is read like one whose weights are 1, so a mesh with such corners no longer leaves the whole file coming in as its page or PDF part; other weights still leave the mesh unread. Checked against the pages of two users' files: one opens from its layers 0.6% from its page, the other with 728 live meshes instead of its PDF part's 65,558 patches. Test `import::ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`; scores unchanged | | 2026-10-30 | minor | `.ai` import: a PDF part whose pages are cropped to the art is recognised in a `UserUnit` page's units too (the art's box in those units, the artboard not the page in points), and its type is read from the first page only, since every such page holds all the art again: the copies on later pages were moved as the first page's and could be put into the layers' text objects. A page cropped to the art is taken only when every page is that box. Tests `import::tests_native::a_user_unit_page_cropped_to_the_art_is_read_in_its_units`, `type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`; scores unchanged | | 2026-10-30 | minor | `.ai` import: when the layers are compared with the page again with placed files at the corner of their box, the text objects the page doesn't draw are weighed with the images there, from the layers as read: before, the text an image covered at its first placement had already been left out, so a file whose image covered type only there came in without that type or as its PDF part. Test `import::tests_native::text_a_placed_file_covers_only_at_its_first_placement_is_kept`; scores unchanged | | 2026-10-30 | minor | `.ai` import: type read from the editing data's text document keeps its case setting (character attribute 12: 1 Small Caps, at Document Setup's default size, 2 All Caps); it came in as typed, so a heading set in All Caps showed in lower or mixed case where the page draws capitals. Test `import::tests_native::type_from_the_text_document_keeps_its_case_setting`; checked locally against a user's files; scores unchanged | | 2026-10-30 | minor | PDF import: a patch or triangle mesh shading of up to 16,384 patches comes in as gradient meshes (it was 4,096; Illustrator writes up to 14,355 in files users sent), and a stroke painted with one (how Illustrator writes a freeform gradient on a stroke) is the meshes clipped to the area the stroke paints, with its width, caps, joins and dashes; both came in as a flat grey. In two files of a user's set, the formerly grey area now matches a reference rendering in 99% of its pixels. Own-fixture tests in `tests::import_fidelity`; scores unchanged | -| 2026-10-30 | minor | `.ai`/EPS editing data: gradient meshes the reader marked unread, which made the whole file come in as its page or PDF part, are read: a mesh starts grey and its `/CS` is written only where it changes, before the corner it applies to (within a patch too), so each corner takes the space last set before it; named colours (`[c m y k /DeviceCMYK (name) /Process]` or `/Spot`) paint at the corner's tint and `/NChannel` inks overprint at theirs; meshes up to 128 patches a side and 16,384 patches are read (files users sent had up to 97 × 72). Ten of eleven such files from a user's set now open from their layers; meshes with handle weights other than 1 still don't. Own-fixture tests in `import::ai::mesh`; scores unchanged | +| 2026-10-30 | minor | `.ai`/EPS editing data: gradient meshes the reader marked unread, which made the whole file come in as its page or PDF part, are read: a mesh starts grey and its `/CS` is written only where it changes, before the corner it applies to (within a patch too), so each corner takes the space last set before it; named colours (`[c m y k /DeviceCMYK (name) /Process]` or `/Spot`) paint at the corner's tint and `/NChannel` inks overprint at theirs; meshes up to 128 patches a side and 16,384 patches are read (files users sent had up to 97 × 72). Ten of eleven such files from a user's set now open from their layers; meshes with handle weights other than 0 or 1 still don't. Own-fixture tests in `import::ai::mesh`; scores unchanged | | 2026-10-30 | minor | `.ai` import: a placed (linked) file is put where its matrix takes the canvas point of art space's origin, and, when the layers then don't draw as the page, at the corner of the box it is written with, which many files use; six files of a user's set whose photos landed off their artboards now open from their layers. Own-fixture test in `import::tests_native`; scores unchanged | | 2026-10-30 | minor | SVG export: axis-aligned rectangles are written as ``, circles and ellipses drawn with four standard arcs as ``/``, straight lines as `` and other straight-segment paths as ``/``, at the written precision; a rectangle or ellipse with a dashed stroke, turned ellipses, curves and compound paths stay `` (#4652); scores unchanged | | 2026-10-30 | minor | `.ai` import: a placed (linked) file written as an object's copy on `%_` lines is read: its `%AI5_BeginPlace` and `%AI5_EndPlace` markers there were dropped, so the `~` that ends it was an unknown operator and the whole file came in as its PDF part; its preview's header, on a `%_` line too, is read. Own-fixture test in `import::ai::tests`; scores unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index b7c58f5a7..e42ecaed0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -152,6 +152,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G9 Illustrator `.ai` and EPS files from real users +- **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out; EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged. + - **Save-back update (2026-10-26):** ordinary EPS Save retains page margins and the whole editable document; source-loss confirmation remains. Import fidelity gaps and estimates below are unchanged. - **Done (2026-10-16):** EPS legacy mesh corners and vector opacity masks retained; winding-declared compound member groups keep shared paint. Raster print-page minification no longer causes a false editing-data fallback; comparison limits remain unchanged. Synthetic regressions cover damage and missing artwork. This narrows G9; scores and estimates unchanged. @@ -657,6 +659,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | G9: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. | | 2026-10-30 | minor | G1: Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J) moves two open path ends to their midpoint and joins them there, the joined anchor a corner or a smooth point as its dialog's Points choice asks, in one undo step; the "Average and Join in one step" entry is done. Estimate unchanged | | 2026-10-30 | minor | G12: Preferences audit (#394): 5 options are stored but not acted on (Open Documents As Tabs, Units › East Asian Type, Hyphenation › Default Language, Scratch Disks, Legacy vector clipboard), each waiting on a subsystem; Highlight Alternate Glyphs, Real-time Drawing and the Devices options act. Estimate unchanged. | | 2026-10-29 | minor | G10: the Linux Flatpak on GNOME copies and pastes text through the core Wayland protocol; pictures and `image/svg+xml` there listed as missing. Estimate unchanged. | diff --git a/docs/image-trace-adjustments.md b/docs/image-trace-adjustments.md index 0de13b953..92e5f42ac 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -46,12 +46,10 @@ most `colors` of its colors are used. A missing group and a group with no solid mode, palette or library in the panel clears the group. A group the active document does not have shows as All and traces as All. -**Document Library linked colors.** With Library › Document Swatches, with or without a color group, -a traced fill or stroke whose color comes from a global or spot swatch is linked to that swatch, and -one whose color comes from a tint swatch is linked to its base at the tint. Editing the swatch -recolors the trace. Colors from process swatches and from swatch libraries are not linked. When -several swatches show the same color, the first of them in the list that is a global, spot or tint -swatch gives the link. +**Document Library colors.** With Library › Document Swatches, with or without a color group, +traced fills and strokes take the library color without a link to its swatch. This includes global, +spot and tint swatches. Tints trace at the color shown by the tint swatch. Editing a swatch later +does not recolor the trace. Colors from other swatch libraries are also unlinked. **Document Library** traces with the library's colors as they are. Each pixel takes the library color nearest to it in lightness and hue, with a difference in hue or chroma counted three times diff --git a/docs/mcp.md b/docs/mcp.md index b7a9e4d5e..c61c0d11c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -416,7 +416,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `inspect_document` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | Artboards (with `color`, `#rrggbb`, for one with a background color), layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. | | `inspect_ui` | `{}` | UI state. Remote mode only. | | `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … | -| `pointer_gesture` | `{events:[{kind,x,y,mods?}], tool?, mods?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. | +| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. | | `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. The options are below. | | `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. | | `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. | @@ -1398,6 +1398,14 @@ selected. While Lock Guides is on, the guide paths count as selected and stay as hide it records no undo step. In isolation mode the menu item is disabled, and the command returns the error "exit isolation mode first" and changes nothing. +`object.lock.unselected` (Cmd+Alt+Shift+2; shortcut and command, with no Object › Lock menu item) locks unselected +artwork in one undo step and returns `{count}`. In a partly selected group, clipping group, or compound path, it locks +the unselected members and leaves the container and selected members unlocked. It includes unselected guides even +while Lock Guides is on. Hidden art and art on locked layers stay individually unlocked; a selected layer's artwork +counts as selected. In ordinary isolation mode it locks only unselected art inside the isolated object. The selection +stays as it is. `object.unlockAll` unlocks and selects all locked artwork in the document, including art locked before +this command. With nothing to lock it records no undo step. + These Select menu commands select only the objects that `select.all` selects and the objects inside them: every `select.same.*` and `select.object.*` command except `select.object.slices`, `select.nextAbove`, `select.nextBelow` and `select.recall`. They leave out hidden and locked objects with the objects inside them, the objects on hidden, @@ -2237,7 +2245,13 @@ reads as Add. The Eyedropper: `appearance.copyFrom {source}` copies what the Eyedropper Options pick up and apply from `source` to the selection (`ids`); `reverse: true` copies the selection's attributes onto `source` (Alt-click), or with nothing selected what the last copy without `reverse` picked up (a copy made then, kept for the session), and `append: true` adds -the source's fills and strokes on top of each target's stack (Shift+Alt-click). The options live in the preferences: +the source's fills and strokes on top of each target's stack (Shift+Alt-click). `defaultsOnly: true` copies the enabled +fill, stroke and weight into paint defaults without changing selected art; it cannot be combined with `ids`, `reverse` +or `append`. The Live Paint Bucket uses this for Alt-click sampling on vector art. On an image, it uses +`paint.sampleColor {color, stroke: false, ids: [], clearStroke: true}` to set a solid fill default from the sampled +pixel and clear the stroke default. `clearStroke: true` requires `stroke: false`, `ids: []` and no `stop`. +Alt+Shift-click samples only the color at the pointer into the active paint default through +`paint.sampleColor {color, ids: []}`. The options live in the preferences: `eyedropper.setOptions {sampleSize?, pickUp?, apply?}` (or `prefs.get`/`prefs.set` with key `eyedropper`) reads and sets them; a tree names flags under `appearance` (`transparency`, `fill` `{color, transparency, overprint}`, `stroke` `{color, transparency, overprint, weight, cap, join, miter, dash}`), `character` and `paragraph`, and a bool for a @@ -3173,8 +3187,10 @@ Pen's second anchor at (150, 431), in line with the square's centre. Cmd+Shift+,) step the size. `type.size.increaseByFive` (Cmd+Alt+Shift+., `press_key {key: ".", mods: {cmd: true, alt: true, shift: true}}`) and `type.size.decreaseByFive` (Cmd+Alt+Shift+,) step it by five times `typeSizeIncrement`. The Type tool's Alt+arrows (`press_key {key: "Right", mods: {alt: true}}`) step kerning at - a caret or tracking of the selection (←/→), leading (↑/↓) and baseline shift (Shift+↑/↓), five steps with Cmd/Ctrl - too. `type.resetTracking` (Cmd+Alt+Q, `press_key {key: "Q", mods: {cmd: true, alt: true}}`) sets tracking to 0 and + a caret or tracking of the selection (←/→ in horizontal type, ↓/↑ with or without Shift in vertical type), + leading (↑/↓ in horizontal type, ←/→ in vertical type), and baseline shift (Shift+↑/↓ in horizontal type, + Shift+→/← in vertical type). Mixed selected text objects use the horizontal mapping; Cmd/Ctrl makes five steps. + `type.resetTracking` (Cmd+Alt+Q, `press_key {key: "Q", mods: {cmd: true, alt: true}}`) sets tracking to 0 and kerning to Auto in the type objects in `ids`, the Type tool's selected text or the selected type objects; at a caret it resets only the kerning of the character before it. `type.resetHorizontalScale` (Cmd+Shift+X, `press_key {key: "X", mods: {cmd: true, shift: true}}`) sets the horizontal scale to 100%, as the Character panel @@ -5761,14 +5777,58 @@ topmost piece. {"name":"run_command","arguments":{"command":"addon.cleanup.simplify","params":{"tolerance":40,"protectCorners":true}}} ``` +### Text tools + +The add-on's text tools act on the selected type (or `ids`), each as one undo step, and refuse with an error when there +is nothing for them to do. The pieces they make are point type that keeps its characters' styles, each placed so the +glyph of its first character lands where it was drawn. + +- `addon.cleanup.breakText {by?, ids?}` turns each horizontal point or area type object into point type objects, one + per line (`by: "lines"`, the default), paragraph (`"paragraphs"`), word (`"words"`) or glyph (`"glyphs"`; a ligature + stays one piece). Spaces between the pieces go. A paragraph keeps its lines: where the layout wrapped one, the piece + has a forced line break. Justified lines close up, and a paragraph broken out whole keeps its bullet (not its + number; the other pieces have no list markers). Type on a path, vertical type, threaded type and area type whose text doesn't fit stay as they are and are + listed in `skipped`. +- `addon.cleanup.combineText {ids?}` merges one-line horizontal point type into as few objects as it can, every glyph + staying where it was (within 0.01 pt). Pieces turned and sheared alike whose baselines lie within 0.4 em of each + other join left to right (a baseline a little off becomes baseline shift); a space goes between pieces at least half + a space apart, and tracking on the character before each piece keeps its place. A gap wider than 10 em, an overlap of + more than 1 em, another look (paint, opacity, effects) or a piece that can't keep its place once joined (a ligature + forming across the join) starts another object. Each object made takes the place and paint of its leftmost piece. The + main use is type from a PDF that arrives one glyph per object. +- `addon.cleanup.removeBreaks {kind, ids?}` removes the forced line breaks (`kind: "line"`) or paragraph returns + (`"paragraph"`) in the characters selected with a Type tool, else in all the text of the selected type. A break + between two words becomes a space; merged paragraphs take the first one's paragraph style. +- `addon.cleanup.splitText {}` works while a Type tool edits horizontal point or area type: the text before the + selected characters, those characters and the text after them become point type objects (at a bare cursor, the text + before and after it). Spaces at the ends of each piece go; a piece that starts inside a left-aligned line takes a + first-line indent, so its later lines stay where they were. Editing ends with the pieces selected. +- `addon.cleanup.alignInPlace {align, ids?}` aligns every paragraph of each horizontal point type object `left`, + `center` or `right` and moves the object so its longest line stays where it is: a single line doesn't move at all. +- `addon.cleanup.resetTextTransform {scale?, ids?}` takes the rotation, shear and reflection off point type, keeping its + size and the centre of its text. With `scale: true` the horizontal scale goes too, the object's and each character's. +- `addon.cleanup.replaceFonts {font?}` sets every character of the document's type whose font family isn't available + in `font` (by default the default type font, Source Sans 3), in that family's style closest to the one it had. + +In the panel, the Text row (which the panel can hide) runs them: Shift-click Lines to break into paragraphs, and +Alt-click Reset Transform to reset the horizontal scale too. Recovering live text from outlines, combining vertical +type and evening out horizontal scales are not there yet. + +```json +{"name":"run_command","arguments":{"command":"addon.cleanup.breakText","params":{"by":"words"}}} +{"name":"run_command","arguments":{"command":"addon.cleanup.combineText","params":{}}} +{"name":"run_command","arguments":{"command":"addon.cleanup.removeBreaks","params":{"kind":"paragraph"}}} +``` + ## Path Precision Tools add-on Path Precision Tools is a built-in add-on for exact anchor and path work: its commands are `addon.pathtools.*`, and in the desktop app it lives in Object › Plug-ins › Path Precision Tools, in its Anchor Inspector panel (`window.panel {panel: "anchorInspector"}`), in its Parametric Shape panel (`window.panel {panel: "parametricShape"}`) -and in four tools that the toolbars don't show until they are added from the All Tools drawer (or chosen with -`tool.select`): `anchorEraserBrush`, `extendPath`, `measureInspect` and `parametricShape`. None has a shortcut. Each edit -is one undo step, and an edit that changes nothing makes no step. +and in seven tools that the toolbars don't show until they are added from the All Tools drawer (or chosen with +`tool.select`): `anchorEraserBrush`, `extendPath`, `measureInspect`, `parametricShape`, `cornerTool`, `slideAnchor` and +`sharedEdge`. +None has a shortcut. Each edit is one undo step, and an edit that changes nothing makes no step. Tools: @@ -5791,6 +5851,29 @@ Tools: gear's tooth height, an arrow's head, a cross's bar, a heart's notch, a bubble's tail tip); each drag runs `addon.pathtools.shape.set`. A double-click on the canvas places a shape with the last settings. Options (`tool.setOption`, or the panel with no shape selected) are the next shape's numbers, as the commands take them. +- **Corner Tool** (`cornerTool`, or the UI command `addon.pathtools.cornerTool`, which also shows the Anchor Inspector + panel with its Corners section): edits the app's Live Corners on any path, selected or not. Over an anchor where two + sides meet at an angle the anchor shows; a click runs `addon.pathtools.cornerSet` with the tool's `radius` (pt, 10) + and `kind` (`round`, `invertedRound` or `chamfer`), and a drag from it sizes the corner (Shift: whole values of the + ruler's unit; no larger than fits). A cut corner shows a marker at the centre of its cut: a click selects the corner + and loads its radius and kind into the tool, a drag resizes it (to 0 it goes), a double-click removes it. An Alt-drag + cuts corners on every anchor in the marquee, replacing those there; a plain marquee selects the corners inside (Shift + toggles), and a click away deselects. The panel section sets `radius` and `kind`, counts the selected corners and has + Apply to Selected Anchors. +- **Slide Anchor Along Path** (`slideAnchor`): a drag on an anchor runs `addon.pathtools.slideAnchor`, previewed while + dragging: the anchor runs along the curve it sits on, as far as just short of its neighbours, and the two segments + beside it are refitted. Shift snaps to where the path's tangent is at a multiple of 45° from the Constrain Angle. Ends + of open paths don't slide. +- **Shared Edge Merge Tool** (`sharedEdge`, or the UI command `addon.pathtools.sharedEdgeTool`, which also shows the + Anchor Inspector panel with its Shared Edges section): works on any two unlocked paths under its round brush, selected + or not. Where they touch at two coincident anchors, hovering shows the two stretches between those anchors dashed and + the edge replacing them solid (green when they are one edge already); a click runs `addon.pathtools.mergeEdge`, half + way between the two. Shift keeps the upper path's outline, Alt the lower's. Where the anchors aren't there yet a click + makes an end: two near anchors meet half way (or, with Shift or Alt, on the path kept), and an anchor only one path has + there gets a match on the other. Shift+Alt makes an end even inside a stretch that could merge. Stretches over 10% + different in length, or at over 120° at an end, are left alone. Options (`tool.setOption`, or the panel, kept with the + preferences): `diameter` (0.25 to 200 pt, 20), `tolerance` (how near two anchors count as coincident, 0 to 3 pt, + 0.002) and `together` (Keep Shared Points Together). Commands: @@ -5823,6 +5906,31 @@ Commands: - `addon.pathtools.crossings {cut?, selfCrossings?, ids?}` adds anchors where the selected paths cross one another (and where each crosses itself, with `selfCrossings`), or with `cut` also cuts them there: a path of one subpath becomes a path per piece. Paths that meet at a shared anchor don't cross there; at most 10,000 segments at once. +- `addon.pathtools.cornerSet {ids?, anchors?, items?, radius, kind?}` cuts live corners, the ones Direct Selection's + corner widgets and the Corners dialog edit, as one undo step. `ids` (default: the selection) are the paths and + `anchors` the anchor indices of each path's outline with no corners cut; without `anchors`, a path's direct-selected + anchors, or all of them. `items: [{id, anchors?}…]` gives each path its own instead. Only anchors where two sides meet + at an angle take a corner: smooth anchors and open ends are skipped, and it is an error if none is left. `radius` is + 0 to 1,000,000 pt, drawn no larger than fits; `kind` is `round`, `invertedRound` or `chamfer` (default: each corner's + own). → `{ids, corners}`. +- `addon.pathtools.cornerRemove {ids?, anchors?, items?}` makes those corners sharp again, as one undo step; a plain path + with none left is plain again. → `{ids, corners}`. +- `addon.pathtools.slideAnchor {id, subpath?, anchor, t}` moves an anchor along its path, the segments beside it refitted + so the outline stays (the anchor count and index stay). `t`, -0.98 to 0.98, says where: positive, a curve parameter on + the segment after the anchor; negative, back from the end of the one before. An anchor at a sharp corner loses it, as + the corner isn't on the way. → `{id, strayed}`, how far the outline moved in points. +- `addon.pathtools.mergeEdge {a, b, at, diameter?, tolerance?, follow?, ends?}` looks within `diameter` (0.25 to 200 pt, + default 20) of `at` ([x, y]) on paths `a` and `b`, two editable paths: where they touch at two anchors coincident within + `tolerance` (0 to 3 pt, default 0.002), one each way along them, it replaces the stretches between with one edge; + otherwise it makes an end there, and `ends: true` asks for that first. `follow` says which outline stays: `average` + (default; half way between), `a` or `b`. Each path stays closed or open, and anchors either stretch has inside are on + the merged edge. One undo step. → `{a, b, made}`, with `made` `edge`, `end`, or `nothing` when they are merged already + (no undo step); it is an error if there is nothing to merge there. +- `addon.pathtools.movePointsTogether {enabled?}` sets Keep Shared Points Together (without `enabled`, turns it the other + way). While it is on, anchors of any editable path coincident, within the Shared Edge Merge Tool's `tolerance`, with + one that `addon.pathtools.slideAnchor`, `extend` or `setAnchor` moves go with it, in the same undo step; an Extend Path + that adds an anchor leaves the old end, and what shares it, where they are. Nothing else in the app changes. It is the + `together` option of tool `sharedEdge`, off by default. → `{enabled}`. - `addon.pathtools.shape.create {center, kind?, width?, height?, rotation?, count?, a?, b?, radius?}` draws a parametric shape with the current paint, selected. `kind`: `rectangle`, `ellipse`, `polygon` (default), `star`, `ring`, `arrow`, `cross`, `heart`, `gear` or `bubble` (a speech bubble). `width` and `height` are 0.01 to 16,384 pt (100); @@ -5854,6 +5962,37 @@ plain path from then on. {"name":"run_command","arguments":{"command":"addon.pathtools.shape.set","params":{"id":14,"kind":"gear","count":16,"radius":2}}} ``` +### Path Effects (live) + +Effect › Path Effects holds the add-on's live geometry effects, applied with `effect.apply {effect, params}` like any +other effect, with no shortcuts. Each draws, measures, exports and stacks as the other geometry effects do; the object's +own path stays as it is until Expand Appearance (`effect.expandAppearance`) writes the result. `effect.list` documents +every parameter. Out-of-range values are clamped, and an effect whose result would pass the geometry effects' budget +(2 million anchors) leaves the path as it is. + +| Id | Menu item | Parameters | +| --- | --- | --- | +| `addon.path.dashes` | Dashes to Geometry… | `dash1`…`dash5`, `gap1`…`gap5` (pt 0 to 10000; 12 and 6, then 0), `offset` (pt), `alignCorners` (bool, false) | +| `addon.path.addAnchors` | Add Anchors… | `target` (`all`, `curved`, `straight`), `spacing` (`count`, `distance`), `count` (1 to 1000, 1), `distance` (pt, 20), `maxCount` (1 to 1000, 100) | +| `addon.path.extend` | Extend Path… | `startLength`, `endLength` (pt, 10; negative shortens), `startPercent`, `endPercent` (% of the path's length, 0), `startShape`, `endShape` (`straight`, `arc`) | +| `addon.path.close` | Close Path… | `mode` (`straight`, `smooth`) | +| `addon.path.smooth` | Smooth Anchors… | `ratio` (0.01 to 0.5, 0.3905) | +| `addon.path.retract` | Retract Handles… | `handles` (`all`, `shorter`), `length` (pt, 5) | +| `addon.path.reverse` | Reverse Direction | none | +| `addon.path.simplify` | Simplify Anchors… | `tolerance` (0 to 200, 10; each step lets the outline drift 0.02 pt), `protectCorners` (bool, true) | +| `addon.path.split` | Split at Anchors… | `anchors` (`corner`, `smooth`, `both`) | + +Dashes to Geometry turns the path into open subpaths, one per dash; with `alignCorners`, every run between corners and +ends holds whole periods with a dash centred on each corner and end, and the offset doesn't apply. A dash of 0 becomes a +point that a round or projecting cap paints. Extend Path adds an end's length and percent together; closed subpaths stay +as they are, and so does an end that would be shortened to nothing. An arc follows the end's curvature for at most half +a circle. Split at Anchors tells smooth anchors by their handles standing in line. Simplify Anchors leaves a path of more +than 50000 anchors as it is. + +```json +{"id": 1, "method": "engine.execute", "params": {"command": "effect.apply", "params": {"effect": "addon.path.dashes", "params": {"dash1": 8, "gap1": 4, "dash2": 2, "gap2": 4, "alignCorners": true}}}} +``` + ## Smart Pen add-on Smart Pen is a built-in add-on: one tool that draws paths and edits their points, handles and segments without diff --git a/docs/roadmap.md b/docs/roadmap.md index e1f58f3af..aa4fc86c4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (M6: Pathfinder: live compound shapes (#4522): the panel menu's Make, Release and Expand Compound Shape, Alt-clicking a Shape Mode, a selected member's Shape Mode and the Expand button act; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (M5: supersampled exports avoid redundant opaque knockout layers; File formats: `.ai`/EPS import fidelity from a user's file audit; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -19,6 +19,8 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M4: G9: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. | +| 2026-10-30 | minor | M4: File formats: `.ai`/EPS editing-data fidelity from an audit of a user's files: All Caps and Small Caps (#4708); placed files at their box's corner, kept where they draw closer to the page (#4664, #4723, #4742); pages cropped to the art, with `UserUnit` (#4658, #4727); gradient mesh corners with handle weights of 0 and the state written after a mesh (#4738, #4744, #4748); type on a path spaced as the file lays it out (#4743); synthetic regressions in `crates/eps`; scores and estimates unchanged. | | 2026-10-30 | minor | Raster effects: bounded length fields in effect dialogs clamp on commit and reject invalid inputs visibly (#4638); estimates unchanged. | | 2026-10-22 | minor | Reconcile upstream documentation after review F-3; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | | 2026-10-22 | minor | Reconcile upstream documentation after review F-2; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | @@ -88,7 +90,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 | -| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | +| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | @@ -139,6 +141,9 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-30 | patch | M5: opaque solid-fill knockout paths avoid redundant erase/add layers in hard-edge frames and supersampled raster export; exact-pixel regression coverage retains analytical-AA rounding and translucent knockout behavior; scores and estimates unchanged | +| 2026-10-30 | minor | M4: G9: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. | +| 2026-10-30 | minor | M4: File formats: `.ai`/EPS editing-data fidelity from an audit of a user's files: All Caps and Small Caps (#4708); placed files at their box's corner, kept where they draw closer to the page (#4664, #4723, #4742); pages cropped to the art, with `UserUnit` (#4658, #4727); gradient mesh corners with handle weights of 0 and the state written after a mesh (#4738, #4744, #4748); type on a path spaced as the file lays it out (#4743); synthetic regressions in `crates/eps`; scores and estimates unchanged. | | 2026-10-29 | minor | M6: Pathfinder: live compound shapes (#4522): the panel menu's Make, Release and Expand Compound Shape, Alt-clicking a Shape Mode, a selected member's Shape Mode and the Expand button act; scores unchanged | | 2026-10-26 | minor | M4/G9: EPS Save and Save As: an opened EPS saves back to its own path as EPS, keeping the chosen artboard's page frame and transparent margins and the complete editable VectorCraft document; Save's TIFF preview is capped at 1024 px (Export keeps 2048); partly read, preview-only or damaged-editing-data sources still need confirmation before they are overwritten; it does not write Illustrator's own editing data (#868), and G9's real-file import fidelity is unchanged; scores and estimates unchanged | | 2026-10-25 | minor | M10/G1: a selected Grid Repeat shows handles: the one below the grid adds or removes rows, the one right of it columns, the one past its bottom-right corner both, and the sliders between the first two columns and rows set the horizontal and vertical spacing; each drag is one undo step and Escape cancels it (#2691); estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 06f05beb9..e90dd2448 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Color Effects add-on: Duotone and Print Ink Fixes; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -19,6 +19,8 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | File formats: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. | +| 2026-10-30 | minor | File formats: `.ai`/EPS editing-data fidelity from an audit of a user's files: All Caps and Small Caps (#4708); placed files at their box's corner, kept where they draw closer to the page (#4664, #4723, #4742); pages cropped to the art, with `UserUnit` (#4658, #4727); gradient mesh corners with handle weights of 0 and the state written after a mesh (#4738, #4744, #4748); type on a path spaced as the file lays it out (#4743); synthetic regressions in `crates/eps`; evidence added; scores unchanged. | | 2026-10-30 | minor | Effects: bounded length inputs clamp on commit and reject invalid text visibly (#4638); scores unchanged. | | 2026-10-29 | minor | File formats: attached Affinity Recolor Adjustment (RcRA) layers import as color overlays (#4420); scores unchanged. | | 2026-10-29 | minor | Type core: copied text runs retain character attributes across document switches and canvas pastes (#4459); scores unchanged. | @@ -133,7 +135,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | 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 Arrange | 9–14 | estimated; 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), guides lost (#779), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 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); scores and estimates unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), guides lost (#779), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 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 Arrange | 9–14 | estimated; 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 Arrange | 9–14 | estimated | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent snapping to curves and perpendicular snapping to straight segments for lines drawn with the Pen, Curvature and Line Segment tools (Preferences › Smart Guides › Geometric Guides and Control bar › Snapping, #2530; other tool and drag cases remain), "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 | @@ -470,6 +472,7 @@ since 2026-10-01. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. +- **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`. - **TIFF in screen and asset export:** both format menus expose the existing TIFF encoder and Format Settings. Headless UI regressions choose TIFF and check scaled dimensions, artboard selection, filenames, encoding options, saved settings and collected-asset output. Public reference: [Export for Screens](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/export-for-screens.html), updated 2026-09-28, lists TIFF for both artboards and assets. Adobe staff [confirmed support in 30.2.1](https://illustrator.uservoice.com/forums/333657-illustrator-desktop-feature-requests/suggestions/49081034-add-tiff-format-into-export-for-screens-assets-d) on 2026-02-19; the older 2025 Collect assets guide omits TIFF. @@ -611,6 +614,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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. - 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. - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). - While the Type tool edits text, a character typed with AltGr is typed and runs no command. Firefox on Windows reports AltGr as Ctrl and Alt held, so in the web build AltGr+L (ł on a Polish layout) does not also open Layer Options for a new layer, and AltGr+Q (@ on a German layout) does not also run Reset Tracking and Kerning. A Ctrl+Alt chord that types no character runs its command. - With the Artboard tool, Cmd/Ctrl+A after a click on a row of the Artboards panel selects every artboard, as the macOS menu bar's Select All does then; the active artboard stays active, and the art's selection does not change. After a press outside the panel's list, Cmd/Ctrl+A selects every object again. It used to select every object after a click in the panel too. Public reference: [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html). @@ -666,7 +670,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With the Blob Brush, Shift held during a drag keeps the stroke on the horizontal or vertical line through the press point, whichever is nearer to the pointer (90° steps from the Constrain Angle preference), and the points dragged since Shift went down move to the other line when the pointer crosses to it; the points dragged while Shift is up follow the pointer. With the Blob Brush, Alt held before the press, or pressed before the drag has moved, switches to the Smooth tool, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists: the cursor shows the Smooth tool, the drag shows a thin line without the length label, Shift does not constrain it, and the release smooths the selected paths under the drag (`path.smoothRegion`) instead of painting; with nothing selected the release changes no path. A stroke that has moved keeps painting when Alt goes down. Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests/draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). - While Alt is held, the Paintbrush (before a press and while drawing) shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open. + While Alt is held during a Paintbrush stroke or before a press with no path selected that the Smooth tool can act on, the Paintbrush shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. Before a Paintbrush press with such a path selected, Alt shows the Smooth cursor, and holding Alt at the press smooths that path with the Smooth tool's radius and Smooth Tool Options' Fidelity. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open. With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short line), and over an end of a selected open path, where the line continues the path, a path-continuation cursor (the same line leaving a small square), as the page describes. - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays by 0.1 radian at each drag update in which the pointer's document y grows, and back by as much at each one in which it falls, Shift held keeps their turn, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). @@ -759,6 +763,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard, fits it and synchronizes the Artboard tool selection when that tool is active; ordinary object selection is preserved. Actual Next-button regressions cover keyboard/Properties deletion and Undo in `chrome.rs`. Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, `view.screenMode` from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible under the outlines (#1505, fixes #1486). In Presentation Mode, → and ← go to the next and the previous artboard, and a click anywhere goes to the next one, as the status bar navigator's Next and Previous do (`view.goToArtboard`): on the last artboard → and a click leave the view as it is, and so does ← on the first. The arrow keys do not move the selection there, and the tool does not receive a click, so a click selects and draws nothing (behavior reference: [View artwork](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)). Full Screen Mode and Presentation Mode put the window full screen, covering the taskbar and the window's frame, and Normal Screen Mode or Full Screen Mode with Menu Bar puts it back (#4365); a window the user made full screen stays so. + An unmodified Escape in either Full Screen Mode exits to Normal Screen Mode after any open popup, focused field, tool gesture, ruler-origin drag, floating Tools panel move, edit mode, drawer, flyout or view rotation has taken its Escape; a later Escape exits when that action is finished. This is a VectorCraft choice for both full screen variants, based on the public [shortcut list](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html); the list does not specify that priority or the return mode. Presentation Mode shows the active artboard fitted to the screen (fitted again as the window goes full screen) and hides the selection, guides, grids, slices and the artboards' edges and names, as [presentation mode](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/view-artwork-in-presentation-mode.html) describes. In Presentation Mode no key press other than ←, → and Escape runs a command or reaches the tool (tool keys, Delete, Enter, Tab, F, Shift+F and every command shortcut, the macOS menu bar's key equivalents included), a right click or a long touch opens no context menu, a tap of two or three fingers undoes or redoes nothing, and the pointer over the canvas is the default one instead of the tool's cursor. Align to Artboard uses the active artboard, the one the Artboards panel highlights (`object.align {artboard}`). A press with a selection tool inside an artboard makes that artboard active; with the Selection tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. One selected object is aligned to the active artboard too, whatever Align To says (a lone object is never the key object). When an action plays, its Align to Artboard, All on Active Artboard, Paste in Place, Paste in Front and Paste in Back steps that name no artboard use the artboard that is active then. @@ -814,6 +819,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With anchors or segments of a path selected with Direct Selection, Simplify acts on just that part of the path, as the [Manually simplify paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html) page puts it: the slider, Auto-Simplify and the dialog refit only each run of selected segments, between its end anchors, which keep their place and their handles on the side away from the run. The anchors and segments outside the runs stay exactly as they were, a closed path's run may cross its start point, separate runs on one path are refitted independently in one undo step, and the runs stay selected. A path with only lone anchors selected is still simplified whole, and `object.path.simplify {wholePaths: true}` simplifies partly selected paths whole. Tests: `crates/engine/src/tests/simplify_part.rs` and `simplify_slider_and_dialog_simplify_only_the_direct_selected_part_of_a_path`. Object › Path › Smooth… (#4115) opens a slider under the selection from Minimum to Maximum smoothing with an Auto-Smooth button, as the [Smooth slider](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/refine-path-segments-with-the-smooth-slider.html) page describes; the canvas previews it and Enter or a click outside keeps it as one undo step (`object.path.smooth {level, auto, ids}`). The page names only the ends, so the rest is VectorCraft's own rule: each selected path, compound path members included, is refitted with curves within 0.1% (level 0) to 2.5% (level 100) of its longer side, corners kept only where it turns back and never with more anchors (a rectangle stays as it is); Auto-Smooth is level 50. Closed paths stay closed and open ones keep their end points. Tests: `crates/engine/src/tests/smooth_path.rs` and `smooth_slider_previews_auto_smooth_and_keeps_the_result`. - **Devanagari shaping:** with an OpenType Devanagari font, Ka, virama and Ssa (क्ष) form one conjunct glyph, Ra and virama before a consonant (र्क) become a reph drawn after it, the i matra (कि) is drawn before its consonant, and Ka with a nukta is drawn like the precomposed क़ (U+0958), as [Microsoft's Devanagari shaping specification](https://learn.microsoft.com/en-us/typography/script-development/devanagari) describes. Each syllable is one cluster of its source bytes, and the arrow keys stop at its ends. A click inside a syllable drawn with several glyphs puts the caret at the syllable's nearer end, in point, area and path type. Tests: `crates/text/src/tests/devanagari.rs`; they print SKIPPED when no such font is installed. +- **Vertical type spacing shortcuts:** The [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page gives Alt+Down/Up for kerning at a caret or tracking of selected text, Alt+Left/Right for leading, and Alt+Shift+Right/Left for baseline shift. Alt+Shift+Down/Up also steps kerning or tracking. Down increases kerning or tracking, Left increases leading, and Shift+Right increases baseline shift; the opposite arrows decrease them. Cmd/Ctrl makes five steps. Horizontal type keeps its existing mapping; mixed horizontal and vertical text-object selections take the horizontal mapping for both objects in one undo step. Selected vertical point, area and path type use the same directions. Tests: `spacing_arrows_follow_each_text_objects_orientation`, `alt_arrows_rotate_for_selected_vertical_type`, `mixed_type_objects_take_horizontal_spacing_chords_in_both_orders`, and `selected_vertical_area_and_path_type_use_vertical_spacing_chords`. - **Vertical punctuation:** fullwidth `!` (U+FF01) and `?` (U+FF1F) use their nominal glyph upright when no vertical alternate is present, following the Tu fallback in [Unicode UAX #50 §3.1](https://www.unicode.org/reports/tr50/#vo). Code-built test fonts cover point and area type with and without `vert` alternates, retain the rotated fallback for a bracket and long vowel mark, and leave horizontal type unchanged. Tests: `crates/text/src/tests/vertical.rs`. - Free Distort's dialog (Effect › Distort & Transform › Free Distort…) shows a box in the proportions of the first selected object's bounds with a handle on each corner. Dragging a handle moves that corner. A handle focused by a click or Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset puts the four corners back on the box. The preview and OK run `effect.apply`, or `effect.setParams` when the dialog edits an applied effect, and the Appearance panel's inline editor opens the dialog with Edit…. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 285 (PDF page 291). - **Kerning across style runs:** characters that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking that keeps or drops ligatures alike are shaped together, so restyling one character keeps the font's kerning pairs with its neighbors. A character with manual kerning that follows one with automatic kerning keeps the font's kerning with it (unless All Caps or Small Caps sets it as two letters, as ß becomes SS), and its value replaces the font's kerning after it. A ligature does not form across a change of style, and a change of font, size or OpenType features still shapes the characters apart. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/text/src/tests/bidi.rs`. @@ -850,6 +856,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In vertical type, the Tabs panel names its alignment buttons Top-Justified Tab, Center-Justified Tab, Bottom-Justified Tab and Decimal-Justified Tab, in their tool tips and for screen readers, and labels the position field Y. In horizontal type the buttons keep the names Left-Justified Tab and Right-Justified Tab, and the field keeps the label X. The Tabs panel's ruler is numbered in the General unit with the canvas rulers' label steps and a tick every tenth of a step. With the panel menu's Snap to Unit on, or Shift held, a tab stop placed by a click on the ruler or dragged on it lands on the nearest tick (`text.tabs.set`). With Snap to Unit off and Shift not held, a tab stop placed by a click on the Tabs panel's ruler or dragged on it lands on the nearest whole point. A position typed in the X or Y field keeps its hundredths of a point. + The Tabs ruler's upper first-line marker and lower left-indent marker show absolute positions and set paragraph indents (`text.setFormat`). Dragging the lower marker keeps the upper position, while Ctrl-dragging it (Command on macOS) moves both; marker drags snap like tab stops, and each release is one undo step. With differing left indents, only the upper marker is shown and its absolute target applies to every touched paragraph. 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). @@ -859,7 +866,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set. - **Image Trace, thin closed outlines:** generated `fit::snap_area_tests` verify nonzero interiors after straightening, opposite closed-boundary winding and Noise controls; see [quality notes](image-trace-quality.md#thin-closed-outlines-with-snap-curves-to-lines). Scores and hours unchanged; open-piece fitting is unchanged. - **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel. -- **Image Trace, Document Library linked colors:** with Document Swatches, traced fills and strokes from global and spot swatches (and tints of them) are linked to their swatches, so editing a swatch recolors the trace; process colors and swatch-library colors stay unlinked; a code-drawn test in `crates/engine`. +- **Image Trace, Document Library colors:** with Document Swatches, traced fills and strokes use the displayed colors of process, global, spot and tint swatches without links, so editing a swatch leaves the trace unchanged; swatch-library colors are also unlinked; code-drawn tests in `crates/engine`. - **Image Trace, flattened slivers:** in a Color, Grayscale or Black and White trace with Fills, a traced region whose outline straightens onto one line (a single pixel, or a one-pixel row along an edge, its pieces shared with the regions beside it) is left out instead of becoming a selectable path with no area, which paints nothing. Snap Curves To Lines straightens more outlines, so it leaves out more of these. Without Fills, the region's 1 px stroke draws it, and it is kept. Code-drawn tests in `crates/trace`. - **Image Trace, Auto Color rare colours:** the Automatic palette keeps colours too rare for its area count when they are distinct (Oklab) and drawn as coherent regions, at most six, protected from mixed-colour merging and Noise; code-drawn stem, highlight, low-contrast and scattered-noise tests (see [quality notes](image-trace-quality.md#auto-color-keeps-rare-colours)). - **Image Trace, Document Library keeps neutral tones neutral:** each pixel takes the library color nearest in Oklab with differences in hue and chroma counted three times, so a gray or a muted brown traces as a neutral library color instead of a saturated one of similar lightness; a code-drawn test of gray and brown patches with a black, light and blue library in `crates/trace`. @@ -891,6 +898,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Create Strokes uses exact squared Euclidean distances and Guo–Hall thinning in bounded per-blob crops. Maximal-disc pruning, split-crossing merges and degree-two joins simplify its skeleton graph. The whole-blob width rule and ink fitting remain; 46 code-drawn inputs measure ends and junctions in [`image-trace-quality.md`](image-trace-quality.md#centre-lines). Junction degrees measure 44/45 correct in each input variant; remaining spurs are recorded. This is a measured skeleton improvement; the Image Trace score, missing per-feature selection and width controls, and estimates remain unchanged. Object › Live Paint › Merge adds the other selected paths and Live Paint groups to the bottom selected Live Paint group ([Live Paint groups](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/live-paint-groups.html)), which keeps its id, the paint of its faces and edges, and its name, lock, visibility, transparency, appearance, opacity mask and graphic style link. Object › Live Paint › Make with a Live Paint group selected, and a Live Paint Bucket click on a selected path beside a selected Live Paint group, add the paths to that group the same way. Make, Merge and the Live Paint Bucket take paths, compound paths and groups of them; with type, an image or another object among the selection they report that it has to be converted to paths first and change nothing, as type and raster images can't go into a Live Paint group directly ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)); before, such an object went among the group's hidden sources and vanished from the canvas. Evidence: `tests_build::live_paint_refuses_type_and_images_and_changes_nothing`. + With the Live Paint Bucket selected, Alt-click on vector art samples its fill, stroke and stroke weight into the paint defaults ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)). On a placed image it samples the image pixel as a solid fill with no stroke. Alt+Shift-click samples only the color under the pointer, including an intermediate gradient color or image pixels, into the active fill or stroke ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). Neither click paints or changes the selection or selected art, and the next Bucket click paints with the sampled paint. Evidence: `tests_build::live_paint_bucket_alt_sample_paints_next_face_without_changing_selection`. Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)). Double-clicking the Live Paint Selection tool opens its Tool Options ([Live Paint Selection tool options](https://web.archive.org/web/20250904012025/https://helpx.adobe.com/illustrator/using/selecting-objects.html)): with Select Fills off, a click selects an edge instead of a face; with Select Strokes off, Alt-click selects a face instead of an edge; Color and Width set the color and width of the highlight on the face or edge under the pointer (red and 4 px by default). The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintSelection"}`). @@ -908,6 +916,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Missing library files:** a persistent swatch or graphic style library panel whose file is gone at startup reopens and stays in the preferences, showing the file's path with Locate Library… (`ui.libraryPanel.locate`), which reads the file from where it is now into the same panel. - **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"). 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. - **Document Color Mode outside the layers:** File › Document Color Mode (in one undo step) and `document.open {colorMode}` also convert the colors of graph designs, graphic styles, opacity-mask art, the tile that pattern editing's Cancel puts back and the art of brushes (with a Hue Shift brush's key color); a tint of a global swatch there becomes the converted swatch's color at its tint, as in the art. @@ -959,7 +968,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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. Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included. Object › Hide › Unselected Artwork (Cmd/Ctrl+Alt+Shift+3, `object.hide.unselected`) hides, in one undo step, every object Select All takes that is not selected and holds no selected object; the members of a group that holds a selected object are hidden one by one, and Object › Show All shows them again. It is disabled in isolation mode. Public references: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Hide unselected artwork"; [How to lock, hide, and delete objects in Illustrator](https://web.archive.org/web/20220303070637/https://helpx.adobe.com/illustrator/using/locking-hiding-deleting-objects.html), which says the Show All and Hide menus are unavailable in isolation mode. - In isolation mode, the items of Object › Lock and Object › Hide, Unlock All and Show All are greyed out ([lock, hide, and delete objects](https://web.archive.org/web/20220303070637/https://helpx.adobe.com/illustrator/using/locking-hiding-deleting-objects.html)); the Contextual Task Bar's Lock button is greyed out too, the Properties panel's Quick Actions have no Lock, and the commands return an error. Pattern editing mode and opacity mask editing do not count as isolation mode for them. + Cmd/Ctrl+Alt+Shift+2 (`object.lock.unselected`) locks unselected artwork in one undo step and has no Object › Lock menu item. It locks unselected members of partly selected groups, clipping groups and compound paths, and unselected guides while Lock Guides is on. In ordinary isolation mode it locks only unselected art inside the isolated object. Object › Unlock All unlocks all locked art in the document. The [public shortcut page](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) calls the action "Lock all deselected artwork". + In isolation mode, the visible items of Object › Lock and Object › Hide, Unlock All and Show All are greyed out ([lock, hide, and delete objects](https://web.archive.org/web/20220303070637/https://helpx.adobe.com/illustrator/using/locking-hiding-deleting-objects.html)); the shortcut-only `object.lock.unselected` command remains enabled with a selection. The Contextual Task Bar's Lock button is greyed out too, the Properties panel's Quick Actions have no Lock, and the other Lock and Hide commands return an error. Pattern editing mode and opacity mask editing do not count as isolation mode for them. Liquify (its brush Width, Height and Angle, Intensity, Detail, Simplify, Twirl Rate, Complexity, Horizontal and Vertical), Puppet Warp (its Expand and each pin's angle), Transform in Perspective (its depth), Nudge in Perspective (its direction) and Move Plane (its location or distance) refuse a value other than a number or null, such as "40", Draw in Perspective refuses a click point that is not two numbers, and Width Profile refuses a point that is not three numbers; each leaves the document and its undo history as they were. Object › Lock › All Artwork Above and Object › Hide › All Artwork Above go through each top-level layer once, checking each object that is not selected against the selected objects below it: with 100 tiles selected under 25,000 rectangles, Lock took 6.4 s and now takes under 0.1 s, and with all of 5,001 objects selected it took 8.8 s and now takes under 0.1 s. Object › Unlock All and Object › Show All clicked in the in-window menu bar with Shift+Alt (Shift+Option) held unlock or show only the objects inside the groups that hold the selected objects ([lock, hide, and delete objects](https://web.archive.org/web/20230623025912/https://helpx.adobe.com/illustrator/using/locking-hiding-deleting-objects.html)); `object.unlockAll` and `object.showAll` take `scope: "group"` for it. @@ -1076,6 +1086,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur actual OS display and Caps Lock remain unverified. Behaviour reference: [public tool-pointer documentation](https://helpx.adobe.com/illustrator/using/tools.html#change_tool_pointers). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). A Precise Cursors command toggles the preference and can take a shortcut (#1663). On macOS the tool cursor is painted only while the window has the focus. Over a window behind another app, or under the File › Open… sheet, the system pointer shows alone instead of next to the painted cursor (#2020). In a rotated view the resize and rotate cursors turn with the view, to the nearest 45°. - **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. Print-size images (more than 512 MB decoded, up to 2 GB) draw in the canvas and in raster exports and can be trimmed with Crop Image; an image with a side past 65,535 px is shrunk to fit, a raster export says when an image could not be decoded, and Crop Image's trim leaves CMYK images alone (#1517). On Windows `vectorcraft-cli perf` reports the cores busy and, with `--deep`, its working set, and warns on a busy machine as it does on macOS and Linux (#1502). + Hard-edge rendering, including the internal hard-edge frame of Art Optimized supersampled raster exports, draws a knockout element with one opaque solid fill once instead of erasing and adding it through separate layers. Analytical antialiasing keeps the existing compositor and rounding. Synthetic path/compound-hole tests compare exact pixels against the general knockout path across precision, supersampling, translucent parent groups and backdrop blending; translucent fills retain their knockout behavior. An image whose pixels can't be decoded, such as a PNG cut short or an image over the 2 GB decoding allowance as RGBA that can't be drawn at reduced size, is tried once: the renderer notes it and does not try to decode it again on later frames. An image refused only because the images being drawn leave no room for it is tried again on the next render. On the canvas such an image draws as a light gray box crossed from corner to corner, and the status bar says once for each image that it couldn't be decoded; exports leave it out and report it as before. A PNG, JPEG, GIF or WebP image that takes more than 2 GiB as RGBA (512 MiB in the web app) draws in the canvas and in raster exports at the largest size within that allowance, instead of as that crossed box on the canvas and not at all in exports. Each pixel of the result is the mean of the image's pixels it covers, and the document keeps the whole file. A PNG that isn't interlaced is read a row at a time, and other images are read whole when their decoded samples fit in the allowance. A 16-bit PNG that isn't interlaced, whose decoded samples take more than the allowance and whose RGBA fits in it, draws at its own size, read a row at a time. An image of more pixels than the allowance has bytes (on the desktop, 46,341 × 46,341 is more) still draws as the crossed box, and so does one whose decoded samples take more than the allowance, other than a PNG that isn't interlaced. Open, Place and the Links panel say "an image of W × H pixels is shown at reduced size", and say "too large to draw" of an image that is not drawn at reduced size. With `--deep`, each memory row of `vectorcraft-cli perf` also gives the peak so far: the peak working set on Windows and the peak resident memory (`VmHWM`) on Linux. A new last row gives the memory after the SVG export and the save → open → save at the end of the run. @@ -1223,6 +1234,14 @@ Show, Fix Selected and Fix All (each one undo step), Deep Ungroup and Rejoin Pat alone; Fix All as one step; scopes; rejoin within the gap only) and `addons::cleanup::tests` in `vectorcraft-ui-egui` (the submenu opens the panel; Check lists what it finds). Commands: `docs/mcp.md` › Artwork Cleanup add-on. +The add-on's text tools (2026-10-31) work on the shared text layout: Break Text into lines, paragraphs, words or glyphs +and Split Text at Cursor make point type pieces placed by the glyph of their first character, Combine Point Text merges +one-line point type back with every glyph where it was (tracking and spaces, checked against the layout), and Remove +Line / Paragraph Breaks, Set Alignment, Keep Position, Reset Text Transform and Replace Missing Fonts. Tests: +`cmd::addons::cleanup::text::tests` in `vectorcraft-engine` (glyphs kept in place, styles kept, one undo step, +refusals), `addons::cleanup::tests` in `vectorcraft-ui-egui` (the submenu and the panel's Text row run them) and +`cleanup_text_tools_with_junk_editing_ranges` in the command sweep. + ## Path Precision Tools add-on (2026-10-30) A VectorCraft addition, not an Illustrator feature, so no area score moves. Object › Plug-ins › Path Precision Tools, @@ -1236,6 +1255,16 @@ Kernels in `vectorcraft_pathops::addons::pathtools`. Tests: `addons::pathtools` undo step, bad params refused, the brush's stroke one step) and `edit_toolbar::tests::addon_tools_are_in_the_drawer_not_the_toolbars`. Commands: `docs/mcp.md` › Path Precision Tools add-on. +The add-on's live Path Effects (2026-10-31, Effect › Path Effects, `addon.path.*`) are geometry effects, so they draw, +measure, export and expand as the others do: Dashes to Geometry (up to five dash and gap pairs, offset, fitted to corners +and ends), Add Anchors (all, curved or straight segments, by count or distance, capped per segment), Extend Path (each +end by length and percent, shortening when negative, straight or arc), Close Path (straight or smooth), Smooth Anchors, +Retract Handles (all or the short ones), Reverse Direction, Simplify Anchors and Split at Anchors. Code in +`vectorcraft_effects::addons::path` on the kernels above. Tests: `addons::path::tests` in `vectorcraft-effects` (geometry, +caps, junk params and paths), `cmd::addons::pathtools::effect_tests` in `vectorcraft-engine` (drawn live, Expand +Appearance matches the outline and the pixels, saves keep it, a file's junk params), `addons::pathtools::tests` in +`vectorcraft-pathops` and `vectorcraft-ui-egui`, and the command sweep's junk rows. + The add-on's Parametric Shape Tool (2026-10-30) draws ten kinds of shape that stay editable as numbers (rectangle, ellipse, polygon and star with 3 to 1,000 sides or points, ring, arrow, cross, heart, gear, speech bubble; corner radius limited to the middle of a side): a drag from the centre sizes and turns one, the arrow keys change its count and values @@ -1249,6 +1278,33 @@ stored data), `cmd::addons::pathtools::tests` in `vectorcraft-engine` (each comm anchor edits and transforms, the SVG export filter, the tool's gestures), `addons::pathtools::tests` in `vectorcraft-ui-egui` (submenu and panel) and `command_sweep`. +The add-on's Corner Tool (2026-10-31) edits the Live Corners every path already keeps (no second corner store): hover +shows an anchor that can take a corner, a click cuts one with the tool's radius and kind (round, inverted, chamfer), a +drag sizes it (Shift: whole values), a cut corner's marker selects it and loads its values, resizes it, and removes it +at 0 or on a double-click, a corner never grows past what fits, and an Alt-marquee cuts every eligible anchor inside. A +Corners section in the Anchor Inspector sets the radius and kind, counts the selected corners and applies to them. +Slide Anchor Along Path drags an anchor along its curve, the two segments beside it refitted by the keep-shape kernel +(Shift: where the tangent is a multiple of 45°). Commands `addon.pathtools.cornerSet`, `cornerRemove` and `slideAnchor`, +each one undo step; `vectorcraft_pathops::addons::pathtools::slide_anchor`. Tests: `addons::pathtools` in +`vectorcraft-pathops` (the outline stays within 0.05 pt on a circle, straight runs, ends, stops), `addons::corner_tool` +and `addons::slide_anchor` in `vectorcraft-tools`, `cmd::addons::pathtools::tests` in `vectorcraft-engine` (each command +one undo step, bad params refused, the tools' gestures), `addons::pathtools::tests` in `vectorcraft-ui-egui` (submenu, +panel section), `edit_toolbar::tests` and `command_sweep`. + +The add-on's Shared Edge Merge Tool (2026-10-31) works on any two editable paths under its round brush. Where they touch +at two coincident anchors (tolerance 0 to 3 pt), hovering shows both stretches and the merged edge, and a click replaces +both with it, half way between them, or following the upper (Shift) or lower (Alt) path; anchors either stretch has inside +are matched on the other, both paths stay closed or open, and it is one undo step. Near anchors meet on a click and an +anchor one path lacks is added to match. Keep Shared Points Together, an option in the Anchor Inspector, makes Slide Anchor +Along Path, Extend Path and Set Anchor take coincident anchors along; nothing else in the app changes. Commands +`addon.pathtools.mergeEdge` and `movePointsTogether`; `vectorcraft_pathops::addons::pathtools::apply_merge`. Not yet: +growing to adjacent stretches, preview preferences, cycling three candidates, pairs at crossings without anchors, the +tool's Alt-click cut and delete, and shared segments reshaping together. Tests: `addons::pathtools::shared_edge` in +`vectorcraft-pathops` (the merged edge lies half way, topology, unequal anchors, stretches over a closed path's start, +ends), `addons::shared_edge` in `vectorcraft-tools`, `cmd::addons::pathtools::shared_edge::tests` in `vectorcraft-engine` +(one undo step, bad params refused, the tool's click, the option), `addons::pathtools::tests` in `vectorcraft-ui-egui` +(submenu, panel section), `edit_toolbar::tests` and `command_sweep`. + ## Smart Pen add-on (2026-10-30) A VectorCraft addition, not an Illustrator feature, so no area score moves. Object › Plug-ins › Smart Pen selects the @@ -1297,6 +1353,13 @@ add-on. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | File formats: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. | +| 2026-10-31 | minor | Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together; evidence added; scores unchanged. | +| 2026-10-31 | minor | Path Precision Tools add-on: the Corner Tool and Slide Anchor Along Path; evidence added; scores unchanged. | +| 2026-10-30 | patch | Performance: opaque solid-fill knockout paths avoid redundant erase/add compositing in hard-edge frames, including supersampled raster export; analytical antialiasing keeps its existing rounding; scores and estimates unchanged | +| 2026-10-30 | minor | File formats: `.ai`/EPS editing-data fidelity from an audit of a user's files: All Caps and Small Caps (#4708); placed files at their box's corner, kept where they draw closer to the page (#4664, #4723, #4742); pages cropped to the art, with `UserUnit` (#4658, #4727); gradient mesh corners with handle weights of 0 and the state written after a mesh (#4738, #4744, #4748); type on a path spaced as the file lays it out (#4743); synthetic regressions in `crates/eps`; evidence added; scores unchanged. | +| 2026-10-31 | minor | Path Precision Tools add-on (a VectorCraft addition): live Path Effects; evidence added; scores unchanged. | +| 2026-10-31 | minor | Artwork Cleanup add-on (a VectorCraft addition): text tools; evidence added; scores unchanged. | | 2026-10-30 | minor | Color Effects add-on: live Duotone (one to four inks, spot tones kept) and Print Ink Fixes; scores and estimates unchanged | | 2026-10-30 | minor | 3D Extrude & Bevel, Inflate and Rotate apply to type, which stays editable (#4715); scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: live Vector Halftone; scores and estimates unchanged | diff --git a/docs/type-parity.md b/docs/type-parity.md index c4130f0e5..c0220b193 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Character panel menu › Show Snap to Glyph Options shows the Snap to Glyph section (Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides, Anchor Point; `view.snapToGlyph.options`) (#4232); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Highlight Alternate Glyphs leaves the required lam-alef ligature unhighlighted also in older Arabic fonts (such as legacy Arial) that put it in `liga` rather than `rlig` (#394); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -28,7 +28,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing | | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | -| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on; stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607) | +| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607) | | Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) | | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | @@ -71,6 +71,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Highlight Alternate Glyphs leaves the required lam-alef ligature unhighlighted also in older Arabic fonts (such as legacy Arial) that put it in `liga` rather than `rlig` (#394); scores unchanged | | 2026-10-30 | minor | Character panel menu › Show Snap to Glyph Options shows the Snap to Glyph section (Baseline, x-height, Glyph Bounds, Proximity Guides, Angular Guides, Anchor Point; `view.snapToGlyph.options`) (#4232); scores unchanged | | 2026-10-29 | minor | Font fallback keeps a letter and its combining marks in one face; Vietnamese stacked diacritics (Ể, Ứ, Ự) attach in precomposed, decomposed and keyboard forms (#4491); scores and hours unchanged | | 2026-10-25 | minor | Threading ports: a double-click on a threaded port with the Selection tool breaks the thread there (`text.thread.break`); the story flows into the frames before the break; scores unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e8de7ef08..3313a18d2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Color Effects add-on: Duotone and Print Ink Fixes; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -248,7 +248,7 @@ selected objects, as the Appearance panel's Add New Fill and Add New Stroke butt ~110 distinct modified shortcuts on menu commands plus single-key tool shortcuts for the default toolbar (`crates/tools/src/catalog.rs`), a shortcut editor with chords, and saved sets. Cmd/Ctrl+Alt+L (`ui.newLayer`) opens Layer Options for a new layer, as an Alt-click on the Layers panel's New Layer button does, and Cmd/Ctrl+L still adds a layer without the dialog. F8 (`ui.symbolOptions`) opens Symbol Options for a new symbol made from the selection, as the Symbols panel's New Symbol button does. Not yet diffed item by item -against Illustrator's published default shortcut list; estimated ~70% of it. Remaining: the panel shortcuts, +against Illustrator's published default shortcut list; estimated ~70% of it. Remaining: other panel shortcuts, the type shortcuts (tracking/kerning/leading/baseline steps with Alt/Shift), and selection shortcuts in the Layers panel. The shortcut editor also lists the commands that are in no menu but run without parameters (Edit › Duplicate, panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1539). It lists each button of the @@ -326,10 +326,14 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Tabs panel: a stop drags from where the press landed, a stop just added included | done | #1458 | | Outline view keeps template layers in Preview, dimmed | done | #1486; a picture placed to be traced stays visible under the outlines | | Smart Pen add-on (VectorCraft addition): Object › Plug-ins › Smart Pen selects the Smart Pen Tool, which the All Tools drawer lists under its own heading but neither toolbar shows as shipped (no shortcut), and opens the Smart Pen panel (next segment straight or curved; Remove Points Keep Shape, Make Smooth, Make Corner, Retract Handles, Close or Join). The tool draws (click: straight or curved segment, a curve after a straight one tangent; drag: smooth anchor, Alt breaks the handle, Shift 45°, Space moves it; Tab switches a segment; rubber band; close, join, continue) and edits without switching tools (click selects a point, drag moves it, Alt-click removes it keeping the shape, Shift+Alt-click deletes it, double-click toggles smooth/corner; handle drags with Shift and Alt; segment drags reshape, Alt-click adds a point) | done | `addon.pen.*`, `crates/tools/src/addons/smart_pen.rs`, `crates/ui-egui/src/addons/pen.rs` and their tests; not an Illustrator feature, so it is not scored | +| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its seven tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape, Corner Tool, Slide Anchor Along Path, Shared Edge Merge Tool), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; the Corner Tool cuts editable corners on any path (hover shows the anchor, a click uses its radius and kind, a drag sizes the corner, Shift whole values; a corner's marker selects it and loads its values, drags to resize, to 0 or double-clicked removes it; never past what fits; Alt-marquee cuts every anchor inside) with a Corners section in the Anchor Inspector (radius, kind, selected count, Apply to Selected Anchors); Slide Anchor Along Path drags an anchor along its curve with the segments beside it refitted, Shift snapping where the tangent is a multiple of 45°; the Shared Edge Merge Tool shows, over two paths touching at two coincident anchors, the two stretches and the edge merging them, a click merges them (half way; Shift the upper path, Alt the lower), and near anchors meet or an anchor one path lacks is added to match (Shift+Alt asks for this inside a stretch), with a Shared Edges section in the Anchor Inspector (brush diameter, coincidence 0 to 3 pt, Keep Shared Points Together, which takes coincident anchors along with the add-on's own anchor moves); a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/` and their tests; not an Illustrator feature, so it is not scored | +| Path Precision Tools add-on (VectorCraft addition): Effect › Path Effects lists its live effects (Dashes to Geometry, Add Anchors, Extend Path, Close Path, Smooth Anchors, Retract Handles, Reverse Direction, Simplify Anchors, Split at Anchors), each with the effect dialog's live Preview except Reverse Direction, which has no options, and listed in the Appearance panel like any effect | done | `addon.path.*`, `crates/effects/src/addons/path.rs`, `crates/ui-egui/src/addons/pathtools.rs` and their tests; not an Illustrator feature, so it is not scored | | Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its four tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/shape.rs` and their tests; not an Illustrator feature, so it is not scored | +| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its six tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape, Corner Tool, Slide Anchor Along Path), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; the Corner Tool cuts editable corners on any path (hover shows the anchor, a click uses its radius and kind, a drag sizes the corner, Shift whole values; a corner's marker selects it and loads its values, drags to resize, to 0 or double-clicked removes it; never past what fits; Alt-marquee cuts every anchor inside) with a Corners section in the Anchor Inspector (radius, kind, selected count, Apply to Selected Anchors); Slide Anchor Along Path drags an anchor along its curve with the segments beside it refitted, Shift snapping where the tangent is a multiple of 45°; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/` and their tests; not an Illustrator feature, so it is not scored | | Smart Pen add-on (VectorCraft addition): Object › Plug-ins › Smart Pen selects the Smart Pen Tool, which the All Tools drawer lists under its own heading but neither toolbar shows as shipped (no shortcut), and opens the Smart Pen panel (next segment straight or curved; Remove Points Keep Shape, Make Smooth, Make Corner, Retract Handles, Close or Join). The tool draws (click: straight or curved segment, a curve after a straight one tangent; drag: smooth anchor, Alt breaks the handle, Shift 45°, Space moves it; Tab switches a segment; rubber band; close, join, continue) and edits without switching tools (click selects a point, drag moves it, Alt-click removes it keeping the shape, Shift+Alt-click deletes it, double-click toggles smooth/corner; handle drags with Shift and Alt; segment drags reshape, Alt-click adds a point); an Alt-drag away from paths selects the points inside a marquee | done | `addon.pen.*`, `crates/tools/src/addons/smart_pen.rs`, `crates/ui-egui/src/addons/pen.rs` and their tests; not an Illustrator feature, so it is not scored | | Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its three tools (Anchor Eraser Brush, Extend Path, Measure & Inspect), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and runs Add Points at / Split at Crossings; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs` and their tests; not an Illustrator feature, so it is not scored | | Artwork Cleanup add-on (VectorCraft addition): Object › Plug-ins › Artwork Cleanup opens its panel, which checks the selection or the document for 16 kinds of issue, lists them with counts, and shows or fixes the picked rows or all of them, each fix one undo step; Simplify Anchors (Keep Shape), Rejoin Path Pieces and Deep Ungroup open their options first | done | `addon.cleanup.*`, `crates/ui-egui/src/addons/cleanup.rs` and its tests; not an Illustrator feature, so it is not scored | +| Artwork Cleanup add-on text tools (VectorCraft addition): the panel's Text row, which the panel can hide, and Object › Plug-ins › Artwork Cleanup set the alignment of point type keeping its place, break type into lines (Shift-click: paragraphs), words or glyphs, split type at the cursor, reset a text transform (Alt-click: and its horizontal scale), combine point type, remove line or paragraph breaks and replace missing fonts, each one undo step | done | `addon.cleanup.*` text commands, `crates/ui-egui/src/addons/cleanup.rs` and its tests; a VectorCraft addition, so it is not scored | | Color Effects add-on (VectorCraft addition): live Exposure, Desaturate and Invert Colors in Effect › Color Effects and Object › Plug-ins › Color Effects; its panel picks a colour adjustment and its values and adds it as a live effect or applies it as a filter, into the colours, as one undo step; Select by Color Kind selects the objects that use spot colours, carry colour effects, or are RGB, CMYK or grayscale images; Vector Halftone draws an object as halftone dots live (the dots Object › Vector Halftone makes), which Expand Appearance and the exporters write as paths; Duotone prints each colour's tone with one to four inks (a tint of a spot tone's swatch, or the CMYK mix of several), live or as a filter, its dialog a row per tone and the picked tone's curve; Print Ink Fixes (a CMYK document's ink limit, white overprint removed, black overprint set) opens its options from the submenu and the panel's Inks | done | `addon.color.*`, `crates/ui-egui/src/addons/color.rs` and its tests; not an Illustrator feature, so it is not scored | ## Look and feel @@ -371,6 +375,10 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | Path Precision Tools add-on: the Shared Edge Merge Tool and Keep Shared Points Together; scores and estimates unchanged | +| 2026-10-31 | minor | Path Precision Tools add-on: live Path Effects in Effect › Path Effects; scores and estimates unchanged | +| 2026-10-31 | minor | Path Precision Tools add-on: the Corner Tool (editable corners, panel section) and Slide Anchor Along Path; scores and estimates unchanged | +| 2026-10-31 | minor | Artwork Cleanup add-on (a VectorCraft addition): its text tools in the panel and Object › Plug-ins › Artwork Cleanup; scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: live Duotone (one to four inks, spot tones kept) and Print Ink Fixes; scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: live Vector Halftone; scores and estimates unchanged | | 2026-10-30 | minor | Path Precision Tools add-on: the Parametric Shape Tool (ten re-editable shape kinds), its panel and Convert to Parametric Shape; scores and estimates unchanged | diff --git a/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml index 2ca1cd565..f9cee58d6 100644 --- a/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml @@ -17,6 +17,7 @@ runtime-version: '25.08' sdk: org.freedesktop.Sdk command: vectorcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. - --share=ipc # Display: Wayland first; X11 only when no Wayland compositor is running. diff --git a/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml b/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml index 5a61afbba..681763793 100644 --- a/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml @@ -17,6 +17,7 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: vectorcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Keep identical to ai.storyteller.vectorcraft.bundle.yml (the release bundle); comments there. - --share=ipc - --socket=wayland