diff --git a/crates/doc/src/tests/units.rs b/crates/doc/src/tests/units.rs index 5a9265c702..c53f60a227 100644 --- a/crates/doc/src/tests/units.rs +++ b/crates/doc/src/tests/units.rs @@ -79,6 +79,21 @@ fn odd_text_reads_as_nothing_without_panicking() { } } +/// Text from a command or a file can be any length: a sum of thousands of terms reads as nothing +/// instead of exhausting the stack. Up to 128 `+ - * /` signs read; 129 read as nothing. +#[test] +fn a_very_long_expression_reads_as_nothing() { + let sum = |terms: usize| format!("1{}", "+1".repeat(terms - 1)); + assert_eq!(Unit::Points.parse(&sum(129)), Some(129.0)); + assert_eq!(Unit::Points.parse(&format!("10{} mm", "*1".repeat(128))), Some(Unit::Millimeters.to_pt(10.0))); + // Long text with no signs still reads. + assert_eq!(Unit::Points.parse(&format!("{}5", " ".repeat(2_000))), Some(5.0)); + for text in [sum(200_000), format!("1{}", "*1".repeat(200_000)), sum(130), format!("10{} mm", "*1".repeat(129))] { + assert_eq!(Unit::Points.parse(&text), None, "{} bytes", text.len()); + assert_eq!(crate::parse_number(&text), None, "{} bytes", text.len()); + } +} + proptest! { /// Field text made of digits, operators, `%`, spaces and unit letters reads as a finite value /// or as nothing and does not panic. diff --git a/crates/doc/src/units.rs b/crates/doc/src/units.rs index 234513df1d..5fabfece65 100644 --- a/crates/doc/src/units.rs +++ b/crates/doc/src/units.rs @@ -156,7 +156,12 @@ impl Unit { /// before it (`3cm*50%` is 1.5 cm, `3cm/50%` is 6 cm). After `+` or `-`, it adds or takes away /// that share of everything before it (`50pt+25%` is 62.5 pt, `100pt+20pt-50%` is 60 pt). A /// percentage on its own (`50%`) reads as nothing. + /// + /// Text with more than 128 `+ - * /` signs reads as nothing. pub fn parse(self, s: &str) -> Option { + if s.chars().filter(|c| matches!(c, '+' | '-' | '*' | '/')).count() > MAX_MEASURE_OPERATORS { + return None; + } parse_measure(s, self).filter(|v| v.is_finite()) } /// The value naming this unit in the Units preferences (`points`, `millimeters`, @@ -207,6 +212,9 @@ impl Unit { } } +/// The most `+ - * /` signs [`Unit::parse`] reads in one text. +const MAX_MEASURE_OPERATORS: usize = 128; + fn parse_measure(s: &str, default: Unit) -> Option { let s = s.trim(); // Simple arithmetic on the right (Illustrator fields accept "10+5", "100/2", "3in*2"). diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 67677656e6..1c1ca96d9f 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -417,7 +417,7 @@ pub fn effect_catalog() -> Vec { "artistic.roughPastels", "Rough Pastels…", ARTISTIC, - "{strokeLength: 0..40 (6; longer pastel strokes), strokeDetail: 1..20 (10; bristle texture along the strokes), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false)} repaints the object in soft diagonal pastel strokes over a textured surface, keeping its coverage. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults.", + "{strokeLength: 0..40 (6; longer pastel strokes), strokeDetail: 1..20 (10; bristle texture along the strokes), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\"|\"loaded\" (\"canvas\"; \"loaded\" draws the image in textureImage and draws Canvas when there is none; the others are surfaces made in code), textureImage?: {path} | {name?, dataBase64} | string (Load Texture…: an image file that File › Place reads, within the renderer's pixel allowance, stored as its gray levels in a grayscale PNG in base64; a file sets texture to \"loaded\" unless texture is given, and the stored string, as effect.list gives it, is kept as it is), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false)} repaints the object in soft diagonal pastel strokes over a textured surface, keeping its coverage. A loaded texture lies one image pixel per point at 100 %, centered on the object's center and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults.", json!({ "strokeLength": 6.0, "strokeDetail": 10.0, diff --git a/crates/effects/src/pixel.rs b/crates/effects/src/pixel.rs index 0c159f8485..f2c49dadd7 100644 --- a/crates/effects/src/pixel.rs +++ b/crates/effects/src/pixel.rs @@ -155,8 +155,9 @@ pub enum PixelFx { /// `texture_coverage` (0..40) says. Underpainting { brush_size: f64, texture_coverage: f64, texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool }, /// Artistic › Rough Pastels: diagonal pastel strokes `length` (0..40) long, bristle detail - /// (1..20), then a [`Texture`] surface scaled by `scaling` (0.5..2), in relief as strong as - /// `relief` (0..50) and lit from `light`; `invert` turns the surface's heights over. + /// (1..20), then a [`Texture`] surface (a loaded one included) scaled by `scaling` (0.5..2), in + /// relief as strong as `relief` (0..50) and lit from `light`; `invert` turns the surface's + /// heights over. RoughPastels { length: f64, detail: f64, texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool }, /// Blur › Radial Blur around its Blur Center, `center` (0..1 across and down the object's /// bounds; (0.5, 0.5) the object's centre): along arcs (spin, an arc of `amount`°) or along @@ -385,7 +386,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option { "artistic.roughPastels" => PixelFx::RoughPastels { length: num(p, "strokeLength", 6.0).clamp(0.0, 40.0), detail: num(p, "strokeDetail", 10.0).clamp(1.0, 20.0), - texture: Texture::parse(text(p, "texture", "canvas")), + texture: Texture::parse_with(text(p, "texture", "canvas"), p.get(TEXTURE_IMAGE).and_then(Value::as_str)), scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0, relief: num(p, "relief", 4.0).clamp(0.0, 50.0), light: Light::parse(text(p, "lightDirection", "top")), diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index a1e73eab33..34212ddd66 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -1547,6 +1547,42 @@ fn texturizer_draws_a_loaded_texture_as_relief() { assert_eq!(fx("texture.texturizer", json!({"texture": "LOADED", "textureImage": "not a texture"})), fx("texture.texturizer", json!({}))); } +/// Rough Pastels draws a loaded texture as its surface, and Scaling, Relief, Light Direction and +/// Invert act on it as on the built-in ones. +#[test] +fn rough_pastels_draws_a_loaded_texture_as_its_surface() { + let w = 64; + let mid = grey(150); + let rings = stored(12, 10, |x, y| { + let v = ((x as f64 - 5.5).hypot(y as f64 - 4.5) * 0.9).sin().abs(); + let g = (v * 255.0).round() as u8; + [g, g, g, 255] + }); + let run = |mut p: serde_json::Value| { + p["strokeLength"] = json!(0); + let mut d = flat(w, mid); + fx("artistic.roughPastels", p).apply(&mut d, w, w, &space(w, w)); + d + }; + let relief = run(json!({"texture": "loaded", "textureImage": rings, "relief": 20})); + assert_ne!(relief, flat(w, mid)); + for (_, builtin) in TEXTURES { + assert_ne!(relief, run(json!({"texture": builtin, "relief": 20})), "{builtin}"); + } + for (key, value) in [("invert", json!(true)), ("scaling", json!(200)), ("lightDirection", json!("bottomRight")), ("relief", json!(5))] { + let mut p = json!({"texture": "loaded", "textureImage": rings, "relief": 20}); + p[key] = value; + assert_ne!(run(p), relief, "{key}"); + } + assert_eq!(run(json!({"texture": "loaded", "textureImage": rings, "relief": 0})), flat(w, mid)); + let parsed = fx("artistic.roughPastels", json!({"texture": "Loaded", "textureImage": rings})); + assert!(matches!(parsed, PixelFx::RoughPastels { texture: Texture::Loaded(_), .. }), "{parsed:?}"); + // Without a stored texture, or with one that isn't, Loaded draws Canvas. + for p in [json!({"texture": "loaded"}), json!({"texture": "loaded", "textureImage": "not a texture"})] { + assert_eq!(fx("artistic.roughPastels", p), fx("artistic.roughPastels", json!({}))); + } +} + /// The Texture filters' patterns lie in document space around the object's centre: the same at /// twice the resolution, and moving with the object. #[test] diff --git a/crates/effects/src/svg_filter/defaults.rs b/crates/effects/src/svg_filter/defaults.rs index 5ce67fac6b..95a84ca273 100644 --- a/crates/effects/src/svg_filter/defaults.rs +++ b/crates/effects/src/svg_filter/defaults.rs @@ -128,6 +128,7 @@ mod tests { to_doc: Affine::scale(1.0), px: 1.0, center: Point::new(wd / 2.0, wd / 2.0), + size: vectorcraft_geom::Vec2::new(wd, wd), channels: Channels::Rgb, line: 1.0, extent: 0.5 * wd.hypot(wd), diff --git a/crates/engine/src/cmd/addons/pathtools/tests.rs b/crates/engine/src/cmd/addons/pathtools/tests.rs index a72075d233..afa232a7ce 100644 --- a/crates/engine/src/cmd/addons/pathtools/tests.rs +++ b/crates/engine/src/cmd/addons/pathtools/tests.rs @@ -531,8 +531,13 @@ fn an_anchor_edit_makes_it_a_plain_path_and_transforms_keep_it_editable() { s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "count": 6})).unwrap(); assert_eq!((path(&s, id).anchor_count(), steps(&s)), (12, n + 1)); assert!((shape_of(&s, id).unwrap().rotation + 30.0).abs() < 1e-6, "the turn stays"); - // Stretched across its own axes (it is turned): skewed, so no longer. + // Stretched along its turned bounding box: still the shape, wider along its own axis. s.execute("object.scale", &json!({"sx": 150, "sy": 100, "origin": [220, 140]})).unwrap(); + let r = shape_of(&s, id).unwrap(); + assert!((r.width - 300.0).abs() < 1e-6 && (r.height - 200.0).abs() < 1e-6 && (r.rotation + 30.0).abs() < 1e-6, "{r:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + // Stretched across the page's axes (it is turned): skewed, so no longer. + s.execute("object.scale", &json!({"sx": 150, "sy": 100, "origin": [220, 140], "basis": "page"})).unwrap(); assert_eq!(shape_of(&s, id), None); s.execute("edit.undo", &json!({})).unwrap(); assert!(shape_of(&s, id).is_some(), "undo brings it back"); diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 6a1f0d6496..f694a2b144 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -14,7 +14,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer}; use vectorcraft_doc::{Appearance, Document, GapOptions, GapSize, LiveShape, Node, NodeId, NodeKind, Selection, TraceView}; use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath}; @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Live Paint"], Some("Cmd+Alt+X"), - "{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with its front-most covering source's fill; with a Live Paint group among them, add the others to it as livePaint.merge does; a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}", + "{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with the fill rows that draw (shown, with paint and an opacity above 0) of its front-most covering source that has one and an opacity above 0, and each edge with the stroke rows that draw (also a weight above 0) of its source, in order with their settings and with that source's opacity and blending mode (on a path cut into several edges, dashes go on along the path with the lengths set, without the fit of dashes aligned to corners and path ends, arrowheads stay at its own ends, and a width profile or brush stays only on an edge that runs the whole path); a face or edge given no such row has one unpainted row; with a Live Paint group among them, add the others to it as livePaint.merge does; a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}", has_selection_or_ids, lp_make ), @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Live Paint Bucket", [], None, - "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces; when every face it reaches already has the paint, nothing changes (no undo step, and the recent colors stay as they are) → {group, face}", + "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), scope?: \"one\" (default: the face alone) | \"connected\" (a double-click: also each face reached across edges that are hidden or draw no stroke, as a flood from the face; a stroked edge, a hidden face or a Gap Detection gap stops it) | \"samePaint\" (a triple-click: also every face of the group whose fill is match; \"connected\" and \"samePaint\" are refused with points), match?: paint (as the document stores it, the `fill` of a face's appearance; scope samePaint only; default: the face's fill), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces; when every face it reaches already has the paint, nothing changes (no undo step, and the recent colors stay as they are) → {group, face, count (faces reached)}", has_doc, lp_fill ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Live Paint Bucket (Stroke)", [], None, - "{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point; when it already has the paint and the width, nothing changes (no undo step, and the recent colors stay as they are) → {group, edge}", + "{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), scope?: \"one\" (default: the edge alone) | \"connected\" (a double-click: also each edge joined to it, end to end, through edges that draw with stroke paint match) | \"samePaint\" (a triple-click: also every edge of the group that draws with stroke paint match), match?: paint (as the document stores it; scope connected or samePaint only; default: the stroke paint the edge draws with; an edge draws with the paint of its topmost stroke that is shown, with paint, above 0 pt and not fully transparent, and with none when the edge is fully transparent or has no such stroke), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point, skipping hidden edges: each edge reached takes the paint and width in its topmost stroke that is shown, or in a new stroke above the others when it has none; when every edge it reaches already has the paint and the width there, nothing changes (no undo step, and the recent colors stay as they are) → {group, edge, count (edges reached)}", has_doc, lp_stroke_edge ), @@ -414,22 +414,274 @@ fn source_nodes(n: &Node) -> Vec> { if is_live_paint(n) { b::sources(n).and_then(|g| g.children().cloned()).unwrap_or_default() } else { vec![Arc::new(n.clone())] } } +/// Does appearance row `i` draw: it is shown, has paint and an opacity above 0, and a stroke also +/// has a weight above 0? +fn row_draws(i: &AppearanceItem) -> bool { + match i { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none() && f.opacity > 0.0, + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.opacity > 0.0 && s.width > 0.0, + } +} + +/// What a source path gives the faces and edges made from it. +struct SourceStyle { + /// The fill rows that draw, in paint order (none when the path's opacity is 0). + fills: Vec, + /// The stroke rows that draw, in paint order (none when the path's opacity is 0). + strokes: Vec, + /// The top stroke's weight, for an edge given no stroke that draws. + width: f64, + opacity: f32, + blend: BlendMode, +} + +impl SourceStyle { + /// The style of source path `n`: its rows keep their order and settings, gradients fitted to + /// its bounds keep that fit, and the strokes' pen pressure, tilt, bearing and rotation stay + /// with the path. + fn of(n: &Node) -> Self { + let mut a = (*n.appearance).clone().without_pressure(); + if a.has_unplaced_gradient() + && let Some(bounds) = n.geometric_bounds() + { + a.pin_gradients(bounds); + } + let width = a.stroke().map_or(1.0, |s| s.width); + let (mut fills, mut strokes) = (vec![], vec![]); + if n.opacity > 0.0 { + for i in a.items.into_iter().filter(row_draws) { + if i.is_fill() { fills.push(i) } else { strokes.push(i) } + } + } + Self { fills, strokes, width, opacity: n.opacity, blend: n.blend } + } + + /// Give part `n` the rows `rows` of this source with its opacity and blending mode; with no + /// rows, `n` keeps the unpainted row it has. + fn paint(&self, n: &mut Node, rows: &[AppearanceItem]) { + if rows.is_empty() { + return; + } + n.appearance = Appearance { items: rows.to_vec(), ..Default::default() }.into(); + n.opacity = self.opacity; + n.blend = self.blend; + } +} + +/// How far, in points, a place on an edge may be from its source path and still be on it. +const ON_SOURCE: f64 = 0.05; + +/// Does stroke row `i` have settings that depend on where along its path a piece of it lies or +/// which way it runs: dashes, arrowheads, a width profile, a brush, or a gradient along or across +/// the stroke? +fn depends_on_place(i: &AppearanceItem) -> bool { + matches!(i, AppearanceItem::Stroke(s) if s.dash.is_some() || s.start_arrow.is_some() || s.end_arrow.is_some() || s.profile.is_some() || s.brush.is_some() || s.gradient_mode != vectorcraft_doc::StrokeGradientMode::Within) +} + +/// One subpath's segments with the length along it where each starts, then its whole length, and +/// a grid of square cells listing the segments whose bounds, grown by [`ON_SOURCE`], reach into +/// each cell, so finding the segments near a point does not look at every segment. +struct Track { + segs: Vec, + at: Vec, + closed: bool, + cell: f64, + grid: HashMap<(i64, i64), Vec>, + /// Segments that reach into more than [`Track::MAX_CELLS`] cells, or that come after the grid + /// holds [`Track::CELLS_PER_SEGMENT`] entries per segment, looked at for every point. + wide: Vec, +} + +impl Track { + const ACCURACY: f64 = 1e-4; + const MAX_CELLS: i64 = 4096; + const CELLS_PER_SEGMENT: usize = 16; + + fn new(sp: &SubPath) -> Self { + use kurbo::{ParamCurveArclen, Shape as _}; + let mut bp = kurbo::BezPath::new(); + sp.to_bezpath_into(&mut bp); + let segs: Vec = bp.segments().collect(); + let mut at = Vec::with_capacity(segs.len() + 1); + let mut len = 0.0; + for s in &segs { + at.push(len); + len += s.arclen(Self::ACCURACY); + } + at.push(len); + let boxes: Vec = segs.iter().map(|s| s.bounding_box().inflate(ON_SOURCE, ON_SOURCE)).collect(); + // Cells about as wide as an average segment's bounds. + let mean = boxes.iter().map(|b| b.width().max(b.height())).sum::() / boxes.len().max(1) as f64; + let cell = if mean.is_finite() { mean.max(4.0 * ON_SOURCE) } else { 4.0 * ON_SOURCE }; + let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); + let mut wide = vec![]; + // The grid holds at most this many entries in all. + let mut budget = boxes.len().saturating_mul(Self::CELLS_PER_SEGMENT); + for (k, b) in boxes.iter().enumerate() { + let (x0, y0) = Self::cell_of(Point::new(b.x0, b.y0), cell); + let (x1, y1) = Self::cell_of(Point::new(b.x1, b.y1), cell); + let cells = x1.saturating_sub(x0).saturating_add(1).saturating_mul(y1.saturating_sub(y0).saturating_add(1)); + let Some(cells) = usize::try_from(cells).ok().filter(|&c| cells <= Self::MAX_CELLS && c <= budget) else { + wide.push(k); + continue; + }; + budget -= cells; + for x in x0..=x1 { + for y in y0..=y1 { + grid.entry((x, y)).or_default().push(k); + } + } + } + Self { segs, at, closed: sp.closed, cell, grid, wide } + } + + fn cell_of(p: Point, cell: f64) -> (i64, i64) { + ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64) + } + + fn len(&self) -> f64 { + self.at.last().copied().unwrap_or(0.0) + } + + /// The lengths along the subpath to each of its segments' places nearest `p` that are within + /// [`ON_SOURCE`] of it (more than one where the subpath passes `p` again). + fn places(&self, p: Point) -> Vec { + use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest}; + let near = self.grid.get(&Self::cell_of(p, self.cell)).map(Vec::as_slice).unwrap_or_default(); + let mut out = vec![]; + for &k in near.iter().chain(&self.wide) { + let Some(s) = self.segs.get(k) else { continue }; + let n = s.nearest(p, 1e-9); + if n.distance_sq <= ON_SOURCE * ON_SOURCE { + out.push(self.at.get(k).copied().unwrap_or(0.0) + s.subsegment(0.0..n.t).arclen(Self::ACCURACY)); + } + } + out + } + + /// The signed length from `a` to `b` along the subpath (the shorter way round a closed one). + fn step(&self, a: f64, b: f64) -> f64 { + let (d, len) = (b - a, self.len()); + if self.closed && len > 0.0 { d - len * (d / len).round() } else { d } + } + + /// Of `places`, the one nearest along the subpath to `target`. + fn nearest(&self, places: &[f64], target: f64) -> Option { + places.iter().copied().min_by(|x, y| self.step(*x, target).abs().total_cmp(&self.step(*y, target).abs())) + } +} + +/// Where edge `edge` runs along its source path, whose subpaths' tracks are `tracks`: the length +/// along the source subpath to the edge's start, whether the edge starts at that subpath's start and +/// ends at its end (an open subpath), and whether it runs the whole subpath. The middle of the +/// edge's first segment places it, and its ends are taken where they lie that far along the source, +/// so a path that passes a point twice is told apart. An edge that runs against the direction of its +/// source is reversed first, and a closed edge as long as its closed source is started at the anchor +/// where its source starts (one with no anchor there, or a shorter loop cut from a source that +/// crosses itself, does not count as running the whole subpath). `None` when the edge is +/// not a single piece found on the source. +fn edge_run(tracks: &[Track], edge: &mut PathData) -> Option<(f64, bool, bool, bool)> { + use kurbo::{ParamCurve, ParamCurveArclen}; + let [sp] = edge.subpaths.as_slice() else { return None }; + if sp.segment_count() == 0 { + return None; + } + let (a, z) = (sp.anchors.first()?.p, if sp.closed { sp.anchors.first()?.p } else { sp.anchors.last()?.p }); + let first = sp.segment(0); + let mid = first.eval(0.5); + let half = first.subsegment(0.0..0.5).arclen(Track::ACCURACY); + let length = sp.anchor_arc_positions().last().copied().unwrap_or(0.0); + let edge_closed = sp.closed; + // (how far off the fit is, track, start, end, runs forward) for the subpath that fits best. + let mut found: Option<(f64, &Track, f64, f64, bool)> = None; + for track in tracks { + // The start lies half the first segment before its middle along the source, or after it + // when the edge runs the other way: take the place and way that fit best. + let starts_at = track.places(a); + let mut best: Option<(f64, f64, bool)> = None; + for pm in track.places(mid) { + for forward in [true, false] { + let Some(pa) = track.nearest(&starts_at, if forward { pm - half } else { pm + half }) else { continue }; + let miss = (if forward { track.step(pa, pm) } else { track.step(pm, pa) } - half).abs(); + if best.is_none_or(|(m, _, _)| miss < m) { + best = Some((miss, pa, forward)); + } + } + } + let Some((miss, pa, forward)) = best else { continue }; + let target = if forward { pa + length } else { pa - length }; + let Some(pz) = track.nearest(&track.places(z), target) else { continue }; + let miss = miss + track.step(pz, target).abs(); + if found.is_none_or(|(m, ..)| miss < m) { + found = Some((miss, track, pa, pz, forward)); + } + } + let (_, track, pa, pz, forward) = found?; + let (from, to) = if forward { (pa, pz) } else { (pz, pa) }; + if !forward { + edge.reverse(); + } + let open = !track.closed; + let starts = open && from <= ON_SOURCE; + let ends = open && to >= track.len() - ON_SOURCE; + if open { + return Some((from, starts, ends, starts && ends)); + } + // A closed edge runs the whole closed subpath, from the anchor the planar map starts it at. + // Start it at the anchor where the source starts, so settings that follow the path begin there. + let start = track.segs.first().map(|s| s.start())?; + let moved = edge_closed + && (length - track.len()).abs() <= ON_SOURCE.max(track.len() * 1e-4) + && edge.subpaths.first_mut().is_some_and(|sp| match sp.anchors.iter().position(|a| a.p.distance(start) <= ON_SOURCE) { + Some(k) => { + sp.anchors.rotate_left(k); + true + } + None => false, + }); + Some((if moved { 0.0 } else { from }, false, false, moved)) +} + +/// Fit stroke row `row` of a source path to an edge cut from it, which runs as [`edge_run`] says +/// (`None`: not known): its dashes go on from where the edge starts along the source, with the +/// lengths set and without the fit of dashes aligned to corners and path ends, its arrowheads stay only at the source's own ends, and its width profile and brush, which spread +/// over the whole path, stay only on an edge that runs the whole path. +fn fit_to_edge(row: &mut AppearanceItem, run: Option<(f64, bool, bool, bool)>) { + let AppearanceItem::Stroke(s) = row else { return }; + let (from, starts, ends, whole) = run.unwrap_or((0.0, false, false, false)); + if !whole { + if let Some(d) = &mut s.dash + && run.is_some() + { + d.offset += from; + d.align_corners = false; + } + s.profile = None; + s.brush = None; + } + if !starts { + s.start_arrow = None; + } + if !ends { + s.end_arrow = None; + } +} + /// The planar map's inputs from `sources` (paths and compound paths, back → front), with the -/// fill, stroke and stroke width of each. -fn source_shapes(sources: &[Arc]) -> (Vec, Vec<(Paint, Paint, f64)>) { +/// style of each. +fn source_shapes(sources: &[Arc]) -> (Vec, Vec) { let mut lv = vec![]; for n in sources { b::leaf_shapes(n, &mut lv); } let mut shapes = vec![]; - let mut styles: Vec<(Paint, Paint, f64)> = vec![]; + let mut styles: Vec = vec![]; for l in lv { if let Some((path, rule)) = b::node_outline(l) && !path.is_empty() { shapes.push(po::Shape::new(path, rule, styles.len() as u64)); - let a = &l.appearance; - styles.push((a.fill_paint(), a.stroke_paint(), a.stroke().map(|s| s.width).unwrap_or(1.0))); + styles.push(SourceStyle::of(l)); } } (shapes, styles) @@ -462,17 +714,35 @@ fn build_children(d: &mut Document, sources: Vec>, max_gap: Option> = HashMap::new(); for e in edges { - let Some((_, stroke, w)) = styles.get(e.key as usize) else { continue }; + let key = e.key as usize; + let Some(s) = styles.get(key) else { continue }; + let mut path = e.path; + let mut rows = s.strokes.clone(); + if rows.iter().any(depends_on_place) { + let source = tracks + .entry(key) + .or_insert_with(|| shapes.get(key).map(|src| src.path.subpaths.iter().map(Track::new).collect()).unwrap_or_default()); + let run = edge_run(source, &mut path); + for r in &mut rows { + fit_to_edge(r, run); + } + } let id = d.alloc_id(); - let mut n = Node::path(id, e.path, stroke_only(stroke.clone(), *w)); + let mut n = Node::path(id, path, stroke_only(Paint::None, s.width)); n.name = Some(EDGE_NAME.into()); + s.paint(&mut n, &rows); children.push(Arc::new(n)); } Ok((children, nfaces, nedges)) @@ -481,8 +751,9 @@ fn build_children(d: &mut Document, sources: Vec>, max_gap: Option = old.subpaths.iter().map(Track::new).collect(); + let run = n.path_data_mut().and_then(|path| edge_run(&tracks, path)); + let mut a = (*n.appearance).clone(); + for r in &mut a.items { + fit_to_edge(r, run); + } + n.appearance = a.into(); +} diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 1554ef7328..8dcd453650 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -1,8 +1,8 @@ use super::{ Appearance, AppearanceItem, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, - PathData, Point, Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, - is_live_paint, json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, - switch_or, + PathData, Point, Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, + fit_to_old_edge, ids_json, is_live_paint, json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, + source_nodes, source_shapes, switch_or, }; /// `options` as stored on a Live Paint group: none for the defaults. @@ -85,6 +85,9 @@ pub(super) fn lp_make(s: &mut Session, p: &Value) -> Result { Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) } +/// How far, in points, the middle of an edge Merge makes may be from the old edge it lies on. +const ON_OLD_EDGE: f64 = 1e-3; + /// What Merge carries from a face or edge to the part it makes again in its place, another path: /// the appearance, visibility, opacity and blending mode. Gradients fitted to the old outline keep /// that fit, and the strokes' pen pressure, tilt, bearing and rotation stay behind. @@ -125,18 +128,11 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result .ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?; check_live_paint_takes(d, roots)?; let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; - // Paint to carry over: (point inside face, paint) for faces that draw, and (point on edge, - // paint) for every edge. + // Paint to carry over: (point inside face, paint) for faces that draw; edges are matched to the + // old edges below. let keep_faces: Vec<(Point, Carried)> = b::faces(&old).iter().filter(|f| draws(f)).filter_map(|f| Some((po::interior_point(f.path_data()?)?, Carried::of(f)))).collect(); - let keep_edges: Vec<(Point, Carried)> = b::edges(&old) - .iter() - .filter_map(|e| { - let pd = e.path_data()?; - let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?; - Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), Carried::of(e))) - }) - .collect(); + let old_parts = old.clone(); let mut sources = source_nodes(&old); for r in roots { if *r != target { @@ -162,12 +158,24 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result carried.onto(Arc::make_mut(f)); } } - for (pt, carried) in keep_edges { - if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id) - && let Some(ch) = g.children_mut() - && let Some(e) = ch.iter_mut().find(|c| c.id == eid) - { - carried.onto(Arc::make_mut(e)); + // Each edge whose middle lies on an old edge, every piece of an edge an added path cuts + // included, takes the old edge's paint, with its rows fitted to its part of the old edge. + if let Some(ch) = g.children_mut() { + for c in ch.iter_mut().filter(|c| c.name.as_deref() == Some(EDGE_NAME)) { + let Some(mid) = c + .path_data() + .and_then(|pd| pd.subpaths.first()) + .filter(|sp| sp.segment_count() > 0) + .map(|sp| kurbo::ParamCurve::eval(&sp.segment(0), 0.5)) + else { + continue; + }; + let Some(was) = edge_near(&old_parts, mid, ON_OLD_EDGE) else { continue }; + let e = Arc::make_mut(c); + Carried::of(was).onto(e); + if let Some(outline) = was.path_data() { + fit_to_old_edge(e, outline); + } } } let mut merged = Beside::replacing(d, roots); @@ -363,9 +371,52 @@ pub(super) fn lp_gap_options(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids_json(&targets), "faces": faces, "gaps": gaps_json(&gaps), "closed": closed })) } +/// Which parts a Live Paint Bucket click paints besides the one under the pointer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LpScope { + /// The part under the pointer alone (a click). + One, + /// A face and the faces reached from it across edges that draw no stroke ([`b::flood_faces`]), + /// or an edge and the edges joined to it end to end that draw with the same stroke paint + /// ([`b::connected_edges`], [`b::lp_stroke_paint`]) (a double-click). + Connected, + /// Every face with the same fill, or every edge that draws with the same stroke paint, in the + /// group (a triple-click). + SamePaint, +} + +/// The `scope` of `livePaint.fill` and `livePaint.strokeEdge`: `one` when left out or null. +fn scope_param(p: &Value, c: &str) -> Result { + match p.get("scope").filter(|v| !v.is_null()) { + None => Ok(LpScope::One), + Some(v) => match v.as_str() { + Some("one") => Ok(LpScope::One), + Some("connected") => Ok(LpScope::Connected), + Some("samePaint") => Ok(LpScope::SamePaint), + _ => Err(bad(c, "scope must be \"one\", \"connected\" or \"samePaint\"")), + }, + } +} + +/// The `match` of `livePaint.fill` and `livePaint.strokeEdge`: a paint as the document stores it, +/// None when left out or null. With `allowed` false (a scope that compares no paints) a `match` is +/// an error naming `scopes`, the scopes it applies to. +fn match_param(p: &Value, c: &str, allowed: bool, scopes: &str) -> Result> { + let Some(v) = p.get("match").filter(|v| !v.is_null()) else { return Ok(None) }; + if !allowed { + return Err(bad(c, format!("match applies only with scope {scopes}"))); + } + serde_json::from_value::(v.clone()).map(Some).map_err(|_| bad(c, "match must be a paint as the document stores it")) +} + pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { const C: &str = "livePaint.fill"; let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; + let scope = scope_param(p, C)?; + let like = match_param(p, C, scope == LpScope::SamePaint, "\"samePaint\"")?; + if scope != LpScope::One && p.get("points").is_some() { + return Err(bad(C, "scope applies to a click: leave out points")); + } let parse_chunk = |input: &Value, start: Point| -> Result> { let values = input .as_array() @@ -400,7 +451,8 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { let group = object_param(p, "group", C)?; let roots = if group.is_none() { root_ids(s, p)? } else { vec![] }; // The faces the click or drag paints in Live Paint group `gid`: the visible face at the point, - // and every visible face the drag crosses; → (the face at the point, the faces). + // the faces its scope adds, and every visible face the drag crosses; → (the face at the point, + // the faces). let targets_in = |d: &Document, gid: NodeId| -> Result<(NodeId, std::collections::BTreeSet)> { let fid = d .node(gid) @@ -408,6 +460,18 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { .map(|f| f.id) .ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?; let mut targets = std::collections::BTreeSet::from([fid]); + if scope != LpScope::One { + let n = d.node(gid).ok_or(EngineError::NoNode(gid))?; + if scope == LpScope::Connected { + targets.extend(b::flood_faces(n, fid)); + } else { + let like = match &like { + Some(m) => m.clone(), + None => d.node(fid).ok_or(EngineError::NoNode(fid))?.appearance.fill_paint(), + }; + targets.extend(b::faces(n).into_iter().filter(|f| f.visible && f.appearance.fill_paint() == like).map(|f| f.id)); + } + } if let Some(points) = points { let n = d.node(gid).ok_or(EngineError::NoNode(gid))?; // Use the existing region-crossing probes, so even a face narrower than the distance @@ -457,34 +521,36 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { } } }; - let (g, face) = match live { + let (g, face, count) = match live { Some(gid) => { let (fid, targets) = targets_in(&s.doc()?.doc, gid)?; + let count = targets.len(); // Faces that already have the paint stay as they are: when every face has it, nothing // changes and no undo step is recorded. let d = &s.doc()?.doc; let unpainted: Vec = targets.into_iter().filter(|t| d.node(*t).is_none_or(|f| f.appearance.fill_paint() != paint)).collect(); if unpainted.is_empty() { - return Ok(json!({ "group": gid.0, "face": fid.0 })); + return Ok(json!({ "group": gid.0, "face": fid.0, "count": count })); } s.edit("Live Paint Bucket", |d, _| { for target in unpainted { d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone()); } - Ok((gid, fid)) + Ok((gid, fid, count)) })? } None => s.edit("Live Paint Bucket", |d, sel| { let gid = make_live(d, sel, &roots)?.0; let (fid, targets) = targets_in(d, gid)?; + let count = targets.len(); for target in targets { d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone()); } - Ok((gid, fid)) + Ok((gid, fid, count)) })?, }; s.remember_paint(&paint); - Ok(json!({ "group": g.0, "face": face.0 })) + Ok(json!({ "group": g.0, "face": face.0, "count": count })) } pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { @@ -498,31 +564,72 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "width out of range")); } let tol = number_or(p, "tolerance", 4.0, C)?.clamp(0.0, 1000.0); - let (eid, unchanged) = { + let scope = scope_param(p, C)?; + let like = match_param(p, C, scope != LpScope::One, "\"connected\" or \"samePaint\"")?; + let (eid, count, changed) = { let d = &s.doc()?.doc; let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?; - let e = b::visible_edge_near(n, pt, tol).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?; - // An edge that already has the paint and the width (or has no stroke and gets none) stays as - // it is, and no undo step is recorded. - let unchanged = match e.appearance.stroke() { - Some(st) => st.paint == paint && st.width == width, - None => paint.is_none(), + let start = b::visible_edge_near(n, pt, tol).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?; + let eid = start.id; + let like = like.unwrap_or_else(|| b::lp_stroke_paint(start)); + let targets = match scope { + LpScope::One => vec![eid], + LpScope::Connected => b::connected_edges(n, eid, &like), + LpScope::SamePaint => std::iter::once(eid) + .chain(b::edges(n).into_iter().filter(|e| e.visible && e.id != eid && b::lp_stroke_paint(e) == like).map(|e| e.id)) + .collect(), }; - (e.id, unchanged) + let count = targets.len(); + // An edge whose shown stroke already has the paint and the width (or that has no shown stroke + // and gets none) stays as it is; when every edge does, nothing changes and no undo step is + // recorded. + let changed: Vec = targets + .into_iter() + .filter(|t| { + d.node(*t).is_none_or(|e| match shown_stroke(e) { + Some(st) => !(st.paint == paint && st.width == width), + None => !paint.is_none(), + }) + }) + .collect(); + (eid, count, changed) }; - if unchanged { - return Ok(json!({ "group": g.0, "edge": eid.0 })); + if changed.is_empty() { + return Ok(json!({ "group": g.0, "edge": eid.0, "count": count })); } - let edge = s.edit("Live Paint Bucket", |d, _| { - let e = d.node_mut(eid).ok_or(EngineError::NoNode(eid))?; - e.appearance.set_stroke(paint.clone()); - if let Some(st) = e.appearance.stroke_mut() { - st.width = width; + s.edit("Live Paint Bucket", |d, _| { + for target in &changed { + let e = d.node_mut(*target).ok_or(EngineError::NoNode(*target))?; + match shown_stroke_mut(e) { + Some(st) => { + st.paint = paint.clone(); + st.width = width; + } + None => e.appearance.items.push(super::AppearanceItem::Stroke(super::StrokeLayer::new(paint.clone(), width))), + } } - Ok(eid) + Ok(()) })?; s.remember_paint(&paint); - Ok(json!({ "group": g.0, "edge": edge.0 })) + Ok(json!({ "group": g.0, "edge": eid.0, "count": count })) +} + +/// The stroke `livePaint.strokeEdge` paints on Live Paint edge `e`: its topmost stroke that is +/// shown. None when it has no stroke or every stroke is hidden; the command then adds a stroke +/// above the others. +fn shown_stroke(e: &Node) -> Option<&super::StrokeLayer> { + e.appearance.items.iter().rev().find_map(|i| match i { + super::AppearanceItem::Stroke(s) if s.visible => Some(s), + _ => None, + }) +} + +/// [`shown_stroke`], to change. +fn shown_stroke_mut(e: &mut Node) -> Option<&mut super::StrokeLayer> { + e.appearance.items.iter_mut().rev().find_map(|i| match i { + super::AppearanceItem::Stroke(s) if s.visible => Some(s), + _ => None, + }) } /// Every fill and stroke row of face or edge `n`, bottom first, as `livePaint.info` lists them. A diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 041476a2ed..6aba8d4ca6 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -835,11 +835,13 @@ pub(crate) fn set_params(s: &mut Session, p: &Value) -> Result { const GLASS: &str = "distort.glass"; /// Effect › Texture › Texturizer. const TEXTURIZER: &str = "texture.texturizer"; +/// Effect › Artistic › Rough Pastels. +const ROUGH_PASTELS: &str = "artistic.roughPastels"; /// Does effect `id` have Load Texture: a `textureImage` that `effect.apply` reads from an image -/// file (Glass, Texturizer)? +/// file (Glass, Texturizer, Rough Pastels)? pub(crate) fn texture_load_effect(id: &str) -> bool { - id == GLASS || id == TEXTURIZER + id == GLASS || id == TEXTURIZER || id == ROUGH_PASTELS } /// Glass's `textureImage` in `params` as Glass stores it: an image file (`{path}`, or `{name?, diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 6995075d2f..6d972beda8 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -39,7 +39,7 @@ pub mod jxlread; mod legacy_ai; mod load; mod mesh3d; -pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; +pub(crate) use load::{MAX_RASTER_ALLOC, file_clipped, size_note}; mod heifread; mod hpgl; mod jp2read; diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 7ed3fb723d..60aace2351 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -1074,16 +1074,32 @@ pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result { let layer = d.layers[0].id; let id = d.alloc_id(); let xf = Affine::scale_non_uniform(sx, sy); - let mut n = Node::new( + let n = Node::new( id, NodeKind::Image(Box::new(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default(), pixels: None })), ); + let mut n = file_clipped(&mut d, n, psdread::clipping_path(bytes)); n.name = Some(name.to_string()); d.images.insert(key, blob); d.insert(Some(layer), 0, n).map_err(err)?; Ok(d) } +/// Image `image` of `d` clipped by `clip`, the clipping path its file names in the image's pixel +/// space (a PSD or PSB file's, [`psdread::clipping_path`]): a clip group of that path (even-odd, +/// with no paint) and the image. Without one the image stays as it is. +pub(crate) fn file_clipped(d: &mut Document, image: Node, clip: Option) -> Node { + let (Some(mut path), NodeKind::Image(im)) = (clip, &image.kind) else { return image }; + path.transform(im.xf); + let none = vectorcraft_color::Paint::None; + let mut clip = Node::path(d.alloc_id(), path, vectorcraft_doc::Appearance::basic(none.clone(), none, 0.0)); + if let NodeKind::Path { clipping, rule, .. } = &mut clip.kind { + *clipping = true; + *rule = vectorcraft_geom::FillRule::EvenOdd; + } + Node::new(d.alloc_id(), NodeKind::Group { children: vec![std::sync::Arc::new(clip), std::sync::Arc::new(image)], clip: true }) +} + /// Does this import note say that the file has something the document doesn't? fn is_loss(note: &str) -> bool { note == vectorcraft_pdf::OFF_ARTBOARD_NOTE || vectorcraft_eps::is_loss(note) diff --git a/crates/engine/src/cmd/fileio/psdread.rs b/crates/engine/src/cmd/fileio/psdread.rs index 93e7b61f17..28b44d6197 100644 --- a/crates/engine/src/cmd/fileio/psdread.rs +++ b/crates/engine/src/cmd/fileio/psdread.rs @@ -2,7 +2,8 @@ //! File › Place (#918): the merged image, the flattened picture every PSD carries after its layers, //! as a linked PSD shows. Written from Adobe's public "Photoshop File Formats Specification" //! (). File › Open reads the -//! layers with [`super::psdlayers`], which shares this reader. +//! layers with [`super::psdlayers`], which shares this reader. The clipping path a document names +//! ([`clipping_path`]) clips its merged image where it opens or is placed. //! //! Every mode Photoshop saves a merged image in is read: Bitmap, Grayscale, Duotone (as its //! grayscale data), Indexed, RGB, CMYK and Lab, at 1, 8, 16 and 32 bits per channel, stored raw, @@ -14,6 +15,7 @@ use std::io::Read; use vectorcraft_color::cms::{Lab, lab::lab_to_srgb}; +use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath}; /// Photoshop's colour modes (the header's numbers), as far as the merged image is concerned. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -154,30 +156,113 @@ pub(super) fn header<'a>(r: &mut Reader<'a>) -> Result, String> { pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> { let mut r = Reader::new(bytes); let h = header(&mut r).ok()?; - let mut r = Reader::new(h.resources); - while !r.rest().is_empty() { - r.take(4).ok()?; + let (_, _, data) = resources(h.resources, false).find(|(id, _, _)| *id == 0x03ed)?; + let mut d = Reader::new(data); + // Fixed 16.16 values, each with its unit (1: per inch, 2: per centimetre) and the unit the + // size is shown in. + let mut axis = || -> Option { + let v = f64::from(d.u32().ok()?) / 65536.0; + let unit = d.u16().ok()?; + d.u16().ok()?; + Some(if unit == 2 { v * 2.54 } else { v }) + }; + Some((axis()?, axis()?)) +} + +/// The image resource blocks of `resources`, in file order: each one's id, name (the bytes of its +/// Pascal string) and data. Stops at the first block that is cut short. With `signed`, only the +/// blocks whose signature is `8BIM` are given. +fn resources(resources: &[u8], signed: bool) -> impl Iterator { + let mut r = Reader::new(resources); + std::iter::from_fn(move || { + if r.rest().is_empty() { + return None; + } + let ours = r.take(4).ok()? == b"8BIM"; let id = r.u16().ok()?; // A Pascal string padded to an even size, length byte included. - let name = usize::from(r.u8().ok()?); - r.take(name + (name + 1) % 2).ok()?; + let len = usize::from(r.u8().ok()?); + let name = r.take(len).ok()?; + r.take((len + 1) % 2).ok()?; let size = usize::try_from(r.u32().ok()?).ok()?; let data = r.take(size).ok()?; r.take(size % 2).ok()?; - if id == 0x03ed { - let mut d = Reader::new(data); - // Fixed 16.16 values, each with its unit (1: per inch, 2: per centimetre) and the - // unit the size is shown in. - let mut axis = || -> Option { - let v = f64::from(d.u32().ok()?) / 65536.0; - let unit = d.u16().ok()?; - d.u16().ok()?; - Some(if unit == 2 { v * 2.54 } else { v }) - }; - return Some((axis()?, axis()?)); + Some((ours, id, name, data)) + }) + .filter(move |(ours, ..)| *ours || !signed) + .map(|(_, id, name, data)| (id, name, data)) +} + +/// The resource naming the document's clipping path. +const CLIPPING_PATH_NAME: u16 = 2999; + +/// The saved paths' resource ids. +const SAVED_PATHS: std::ops::RangeInclusive = 2000..=2997; + +/// The clipping path a PSD or PSB file names, in its pixel space (x across its width, y down +/// its height): the saved path (resources 2000 to 2997) whose name the clipping path resource +/// (2999) gives, both `8BIM` resources. `None` when the document names none, names a path it doesn't have, or that path +/// has no anchors or more records than [`vectorcraft_geom::path::MAX_READ_ANCHORS`]. Saved paths +/// use the even-odd rule. A path whose initial fill record says the fill starts with all pixels +/// begins with a subpath around the whole image, so its subpaths cut holes in it. +pub fn clipping_path(bytes: &[u8]) -> Option { + let mut r = Reader::new(bytes); + let h = header(&mut r).ok()?; + let (_, _, named) = resources(h.resources, true).find(|(id, _, _)| *id == CLIPPING_PATH_NAME)?; + // A Pascal string, then the flatness and a fill rule, which the specification says is ignored. + let len = usize::from(*named.first()?); + let name = named.get(1..1 + len)?; + let (_, _, data) = resources(h.resources, true).find(|(id, n, _)| SAVED_PATHS.contains(id) && *n == name)?; + // A path of more records than a document may hold anchors is left out before it is built. + if data.len() / PATH_RECORD > vectorcraft_geom::path::MAX_READ_ANCHORS { + return None; + } + let path = saved_path(data, f64::from(h.width), f64::from(h.height)); + (!path.is_empty()).then_some(path) +} + +/// One record of a saved path: a selector, then 24 bytes. +const PATH_RECORD: usize = 26; + +/// The saved path of path records `data`, scaled to an image `width` × `height` pixels. Knot +/// records come after their subpath's length record; one that comes before any, or past the knots +/// a length record counts, is left out, as is a last record cut short. +fn saved_path(data: &[u8], width: f64, height: f64) -> PathData { + // A path point: a vertical, then a horizontal signed 8.24 fixed-point fraction of the image's + // height and width. + let fixed = |b: [u8; 4]| f64::from(i32::from_be_bytes(b)) / f64::from(1 << 24); + let point = |[v0, v1, v2, v3, h0, h1, h2, h3]: [u8; 8]| Point::new(fixed([h0, h1, h2, h3]) * width, fixed([v0, v1, v2, v3]) * height); + let mut subpaths: Vec = vec![]; + let mut all_pixels = false; + // The knots the current subpath still takes. + let mut left = 0usize; + let (records, _) = data.as_chunks::(); + for &[s0, s1, ref body @ ..] in records { + let [c0, c1, ..] = *body; + let (points, _) = body.as_chunks::<8>(); + let [a, b, c] = [0, 1, 2].map(|i| points.get(i).copied().map_or(Point::ZERO, point)); + match u16::from_be_bytes([s0, s1]) { + sel @ (0 | 3) => { + left = usize::from(u16::from_be_bytes([c0, c1])); + // Anchors are added as knots come: a count can claim more than follow. + subpaths.push(SubPath::new(vec![], sel == 0)); + } + sel @ (1 | 2 | 4 | 5) if left > 0 => { + let Some(sp) = subpaths.last_mut() else { continue }; + left -= 1; + let kind = if matches!(sel, 1 | 4) { AnchorKind::Smooth } else { AnchorKind::Corner }; + sp.anchors.push(Anchor { h_in: a, p: b, h_out: c, kind }); + } + 8 => all_pixels = [c0, c1] == [0, 1], + _ => {} } } - None + subpaths.retain(|s| !s.anchors.is_empty()); + if all_pixels && !subpaths.is_empty() { + let all = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, width, height)); + subpaths.splice(0..0, all.subpaths); + } + PathData::new(subpaths) } /// Does the merged image keep its transparency in the first extra channel? Yes when the layer diff --git a/crates/engine/src/cmd/fileio/tests_psdread.rs b/crates/engine/src/cmd/fileio/tests_psdread.rs index 08a46f0e10..4fe5f1e49b 100644 --- a/crates/engine/src/cmd/fileio/tests_psdread.rs +++ b/crates/engine/src/cmd/fileio/tests_psdread.rs @@ -1,9 +1,11 @@ //! Reading Photoshop documents (#918): the merged image of every colour mode, depth and -//! compression, PSB files, transparency, the resolution, and damaged files refused without a panic. +//! compression, PSB files, transparency, the resolution, the clipping path, and damaged files +//! refused without a panic. use serde_json::{Value, json}; +use vectorcraft_doc::{Document, NodeId, NodeKind}; -use super::psdread::{decode, is_psd, resolution}; +use super::psdread::{clipping_path, decode, is_psd, resolution}; use super::*; /// A Photoshop document built from its parts: `version` 1 (PSD) or 2 (PSB), the header's fields, @@ -260,6 +262,199 @@ fn pdd_files_open_and_place() { assert!(format_for_name("x.pdd").is_none() && writable_format(None, Some("x.pdd")).is_err(), "an export to .pdd is refused as before"); } +/// An image resource block: `8BIM`, `id`, `name` as a Pascal string padded to an even size, then +/// `data` padded to an even size. +fn resource(id: u16, name: &[u8], data: &[u8]) -> Vec { + let mut r = b"8BIM".to_vec(); + r.extend(id.to_be_bytes()); + r.push(name.len() as u8); + r.extend(name); + if name.len().is_multiple_of(2) { + r.push(0); + } + r.extend((data.len() as u32).to_be_bytes()); + r.extend(data); + if !data.len().is_multiple_of(2) { + r.push(0); + } + r +} + +/// A path point record's 8 bytes: `[x, y]` as fractions of the image's width and height, stored +/// vertical first as signed 8.24 fixed-point numbers. +fn point([x, y]: [f64; 2]) -> Vec { + [y, x].iter().flat_map(|v| ((v * f64::from(1 << 24)).round() as i32).to_be_bytes()).collect() +} + +/// A saved path's records: the fill rule record, an initial fill record saying whether the fill +/// starts with all pixels, then each subpath (closed or not) of corner knots without handles. +fn saved_path(all_pixels: bool, subpaths: &[(bool, &[[f64; 2]])]) -> Vec { + let record = |selector: u16, body: Vec| { + let mut r = selector.to_be_bytes().to_vec(); + r.extend(body); + r.resize(26, 0); + r + }; + let mut d = record(6, vec![]); + d.extend(record(8, vec![0, u8::from(all_pixels)])); + for (closed, knots) in subpaths { + d.extend(record(if *closed { 0 } else { 3 }, (knots.len() as u16).to_be_bytes().to_vec())); + for k in *knots { + d.extend(record(if *closed { 2 } else { 5 }, [point(*k), point(*k), point(*k)].concat())); + } + } + d +} + +/// The clipping path resource (2999) naming the saved path `name`, with a flatness of 0 and the +/// fill rule left as it is. +fn clipping_name(name: &[u8]) -> Vec { + let mut d = vec![name.len() as u8]; + d.extend(name); + d.extend([0; 6]); + resource(2999, b"", &d) +} + +/// A 100 × 50 pixel red RGB document with `resources`, flat or with a layer count of 0. +fn red_psd(resources: &[u8]) -> Vec { + psd(1, 3, (100, 50), 8, 3, &[], resources, &[], &raw(&[&[255; 5000], &[0; 5000], &[0; 5000]])) +} + +/// The triangle saved as "Outline", another path saved as "Other", and the clipping path naming +/// "Outline". +fn triangle_resources() -> Vec { + let other = saved_path(false, &[(true, &[[0.0, 0.0], [0.1, 0.0], [0.1, 0.1]])]); + let outline = saved_path(false, &[(true, &[[0.5, 0.0], [1.0, 1.0], [0.0, 1.0]])]); + [resource(2000, b"Other", &other), resource(2001, b"Outline", &outline), clipping_name(b"Outline")].concat() +} + +/// The clip group a PSD file comes in as: its clipping path's anchors (pt), whether that path +/// uses the even-odd rule, and the image it clips. +fn clip_group(d: &Document, id: NodeId) -> (Vec<[f64; 2]>, bool, NodeId) { + let Some(NodeKind::Group { children, clip: true }) = d.node(id).map(|n| &n.kind) else { panic!("not a clip group: {:?}", d.node(id)) }; + let [path, image] = &children[..] else { panic!("{} children", children.len()) }; + let NodeKind::Path { path: p, rule, clipping: true, .. } = &path.kind else { panic!("not a clipping path") }; + assert!(matches!(image.kind, NodeKind::Image(_))); + let anchors = p.anchors().map(|(_, _, a)| [a.p.x, a.p.y]).collect(); + (anchors, *rule == vectorcraft_geom::FillRule::EvenOdd, image.id) +} + +#[test] +fn the_clipping_path_clips_the_merged_image_on_place_and_open() { + let f = red_psd(&triangle_resources()); + let triangle = vec![[50.0, 0.0], [100.0, 50.0], [0.0, 50.0]]; + let data = vectorcraft_format::base64_encode(&f); + // Place: one clip group of the named path, not the other, at 100 % (72 ppi: a pixel a point). + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + let r = s.execute("file.place", &json!({"name": "cut.psd", "dataBase64": data, "at": [50, 25]})).unwrap(); + let id = NodeId(r["ids"][0].as_u64().unwrap()); + let (anchors, even_odd, image) = clip_group(&s.doc().unwrap().doc, id); + assert_eq!((anchors, even_odd), (triangle.clone(), true)); + assert_eq!(r["image"], image.0, "{r}"); + assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(100.0), Some(50.0))); + // Open with layers off, and a flat file opened with layers on, as its merged image. + for params in [json!({"layers": false}), json!({})] { + s.execute("document.open", &merge(json!({"name": "cut.psd", "dataBase64": data}), params.clone())).unwrap(); + let d = &s.doc().unwrap().doc; + let top = d.layers[0].children().unwrap(); + assert_eq!(top.len(), 1, "{params}"); + assert_eq!(top[0].name.as_deref(), Some("cut.psd"), "{params}"); + let group = top[0].id; + let (anchors, even_odd, _) = clip_group(d, group); + assert_eq!((anchors, even_odd), (triangle.clone(), true), "{params}"); + // The corner outside the triangle draws nothing; its middle draws red. + let png = image::load_from_memory(&exported(&mut s, json!({"format": "png"}))).unwrap().to_rgba8(); + assert_eq!((png.get_pixel(2, 2).0[3], png.get_pixel(50, 40).0), (0, [255, 0, 0, 255]), "{params}"); + // The clipping path is an ordinary path: releasing the clipping mask leaves the image whole. + s.execute("select.set", &json!({"ids": [group.0]})).unwrap(); + s.execute("object.clippingMask.release", &json!({})).unwrap(); + let png = image::load_from_memory(&exported(&mut s, json!({"format": "png"}))).unwrap().to_rgba8(); + assert_eq!(png.get_pixel(2, 2).0, [255, 0, 0, 255], "{params}"); + } +} + +/// Replace keeps a clipped PSD file's scale, as it keeps an image's. +#[test] +fn replacing_a_clipped_psd_keeps_its_scale() { + let data = vectorcraft_format::base64_encode(&red_psd(&triangle_resources())); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s.execute("file.place", &json!({"name": "cut.psd", "dataBase64": data, "at": [200, 200]})).unwrap(); + s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap(); + let r = s.execute("file.place", &json!({"name": "cut.psd", "dataBase64": data, "replace": true})).unwrap(); + assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(200.0), Some(100.0)), "{r}"); + let (anchors, _, _) = clip_group(&s.doc().unwrap().doc, NodeId(r["ids"][0].as_u64().unwrap())); + assert_eq!(anchors, [[200.0, 150.0], [300.0, 250.0], [100.0, 250.0]]); + // Other art replacing the clip group takes its centre only, as before. + let plain = vectorcraft_format::base64_encode(&red_psd(&[])); + let r = s.execute("file.place", &json!({"name": "plain.psd", "dataBase64": plain, "replace": true})).unwrap(); + assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(100.0), Some(50.0)), "{r}"); +} + +#[test] +fn a_fill_that_starts_with_all_pixels_puts_the_image_around_the_path() { + let hole = saved_path(true, &[(true, &[[0.25, 0.25], [0.75, 0.25], [0.75, 0.75], [0.25, 0.75]])]); + let f = red_psd(&[resource(2000, b"Hole", &hole), clipping_name(b"Hole")].concat()); + let path = clipping_path(&f).unwrap(); + let starts: Vec<_> = path.subpaths.iter().map(|s| (s.anchors[0].p.x, s.anchors[0].p.y, s.closed)).collect(); + assert_eq!(starts, [(0.0, 0.0, true), (25.0, 12.5, true)], "the image's edges, then the hole"); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "hole.psd", "dataBase64": vectorcraft_format::base64_encode(&f), "layers": false})).unwrap(); + let png = image::load_from_memory(&exported(&mut s, json!({"format": "png"}))).unwrap().to_rgba8(); + assert_eq!((png.get_pixel(2, 2).0, png.get_pixel(50, 25).0[3]), ([255, 0, 0, 255], 0)); +} + +#[test] +fn files_naming_no_clipping_path_or_a_missing_one_place_a_plain_image() { + let other = saved_path(false, &[(true, &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]])]); + let empty = saved_path(false, &[]); + for (resources, what) in [ + (resource(2000, b"Other", &other), "saved paths, none named"), + ([resource(2000, b"Other", &other), clipping_name(b"Gone")].concat(), "a name no path has"), + ([resource(2000, b"Empty", &empty), clipping_name(b"Empty")].concat(), "a path with no knots"), + ([resource(3000, b"Other", &other), clipping_name(b"Other")].concat(), "a resource outside the saved paths"), + ([resource(2000, b"Other", &other), [&b"MeSa"[..], &clipping_name(b"Other")[4..]].concat()].concat(), "a block not signed 8BIM"), + (vec![], "no resources"), + ] { + let f = red_psd(&resources); + assert!(clipping_path(&f).is_none(), "{what}"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("file.place", &json!({"name": "a.psd", "dataBase64": vectorcraft_format::base64_encode(&f)})).unwrap(); + let id = NodeId(r["ids"][0].as_u64().unwrap()); + assert!(matches!(s.doc().unwrap().doc.node(id).map(|n| &n.kind), Some(NodeKind::Image(_))), "{what}"); + assert!(r.get("image").is_none(), "{what}: {r}"); + } +} + +#[test] +fn damaged_clipping_paths_are_read_without_a_panic() { + let good = red_psd(&triangle_resources()); + assert!(clipping_path(&good).is_some()); + // The header, the empty colour mode data, then the resources' length and the resources. + let start = 26 + 4; + let end = start + 4 + triangle_resources().len(); + for n in 0..end + 2 { + let _ = clipping_path(&good[..n]); + } + for i in start..end { + for v in [0, 1, 0x7f, 0x80, 0xff] { + let mut f = good.clone(); + f[i] = v; + let _ = clipping_path(&f); + } + } + // Knots before any length record, and more knots than a length record counts, are left out. + let mut extra = saved_path(false, &[(true, &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]])]); + extra[26 * 2 + 3] = 2; + let mut stray = 2u16.to_be_bytes().to_vec(); + stray.resize(26, 0); + extra.splice(26..26, stray); + let path = clipping_path(&red_psd(&[resource(2000, b"P", &extra), clipping_name(b"P")].concat())).unwrap(); + assert_eq!(path.subpaths.iter().map(|s| s.anchors.len()).collect::>(), [2]); +} + #[test] fn damaged_and_unsupported_files_are_refused_without_a_panic() { let good = psd(2, 4, (3, 2), 8, 3, &[], &[], &layer_count(-1, true), &raw(&[&[1; 6], &[2; 6], &[3; 6], &[4; 6]])); diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index f167a124d6..b228341190 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -7372,8 +7372,8 @@ fn graph_commands_refuse_params_of_the_wrong_type() { } /// A `.ai` in PostScript form whose graph's records read whole (version 7 specification, appendix A) -/// opens as a graph whose data can be edited: Graph Data redraws it on its plot. Saving it as `.ai` -/// says its data and settings aren't written, in either form. +/// opens as a graph whose data can be edited: Graph Data redraws it on its plot. Saving it as `.ai`, +/// in either form, writes its records, and the file opens again with the graph's new data. #[test] fn a_graph_opened_from_ai_keeps_its_data_for_editing() { let graph = "%_Gs\n%_100 200 300 100 Gb\n%_5 0 0 14 0 1 0 0 6 44 Gy\n%_10 2 0 0 90 80 0 Gd\n\ @@ -7430,10 +7430,60 @@ fn a_graph_opened_from_ai_keeps_its_data_for_editing() { } let r = s.execute("file.saveAs", &p).unwrap(); let notes = r["warnings"].as_array().cloned().unwrap_or_default(); - assert!(notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))), "{name}: {r}"); + assert!(!notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))), "{name}: {r}"); + let mut again = Session::new(); + again.execute("document.open", &json!({"path": dir.join(name)})).unwrap(); + let mut graphs = vec![]; + again.doc().unwrap().doc.walk(|n| graphs.extend(n.extras().graph.as_deref().cloned())); + assert_eq!(graphs.len(), 1, "{name}"); + let g = &graphs[0]; + assert_eq!( + (g.series.as_slice(), g.categories.as_slice(), g.rows.as_slice()), + (&after.series[..], &after.categories[..], &after.rows[..]), + "{name}" + ); + assert_eq!(g.kind, after.kind, "{name}"); } } +/// A graph the graph tools draw, of each type, saved as `.ai` in either form, opens again as the same +/// graph exactly when the save gives no warning about graphs; a column graph does. +#[test] +fn a_drawn_graph_saved_as_ai_opens_as_a_graph_unless_the_save_warns() { + let dir = vectorcraft_testkit::temp_dir("graph-ai-save"); + let mut live = vec![]; + for ty in ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,1,6"; + let id = s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(); + let written = spec(&s, NodeId(id)); + for (name, version) in [("v7.ai", Some("7")), ("pdf.ai", None)] { + let path = dir.join(format!("{ty}-{name}")); + let mut p = json!({"path": path, "format": "ai"}); + if let Some(v) = version { + p["aiVersion"] = json!(v); + } + let r = s.execute("file.saveAs", &p).unwrap(); + let notes = r["warnings"].as_array().cloned().unwrap_or_default(); + let warned = notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))); + let mut again = Session::new(); + again.execute("document.open", &json!({"path": path})).unwrap(); + let mut graphs = vec![]; + again.doc().unwrap().doc.walk(|n| graphs.extend(n.extras().graph.as_deref().cloned())); + assert_eq!(graphs.len(), usize::from(!warned), "{ty} {name}: {r}"); + if let Some(g) = graphs.first() { + assert_eq!((g.kind, g.cells()), (written.kind, written.cells()), "{ty} {name}"); + assert_eq!((g.series.as_slice(), g.categories.as_slice()), (&written.series[..], &written.categories[..]), "{ty} {name}"); + live.push(format!("{ty} {name}")); + } + } + } + assert!(live.contains(&"column v7.ai".to_string()) && live.contains(&"column pdf.ai".to_string()), "{live:?}"); +} + /// A graph opened from a `.ai` keeps the bounds of its art without type, so moving one of its labels on its own /// leaves the plot where it is at the first redraw. #[test] diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index b86f260cd8..b8c4199623 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Scale…", ["Object", "Transform"], None, - "{ids?: [id…] (selected first; default: the selection), sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale), objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)} (strokes/corners default to the preferences). With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + "{ids?: [id…] (selected first; default: the selection), sx: % (along the bounding box's width), sy?: % (along its height), origin?: [x,y] (a page point), basis?: \"box\"|\"page\" (box, the default: sx and sy follow the bounding box, turned with rotated objects until Reset Bounding Box; page: the page's axes; a saved step without basis, recorded before it, plays along the page's axes), copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale), objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)} (strokes/corners default to the preferences). With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", has_selection, scale ), @@ -52,7 +52,7 @@ pub fn specs() -> Vec { "Reflect…", ["Object", "Transform"], None, - "{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg (the mirror line, counter-clockwise from horizontal; 0: horizontal; omitted: vertical), origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + "{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg (the mirror line, counter-clockwise from horizontal; 0: horizontal; omitted: vertical; vertical and horizontal are the bounding box's own axes, deg a line on the page), origin?, basis?: \"box\"|\"page\" (for a named axis; box, the default: the bounding box's axes, turned with rotated objects until Reset Bounding Box; page: the page's axes; a saved step without basis, recorded before it, plays along the page's axes), copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", has_selection, reflect ), diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index d102068947..88362d7c49 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -1,7 +1,7 @@ use super::align::choice; use super::{ - Affine, Arc, Document, EngineError, LastTransform, Node, NodeId, NodeKind, Point, Result, Scaling, Session, Value, Vec2, bad, bool_or, - check_numbers, check_switches, duplicate_in, effects, f64_or, f64_req, ids_param, json, matrix_param, ok, outermost, point_param, + Affine, Arc, Document, EngineError, LastTransform, Node, NodeId, NodeKind, OrientedBox, Point, Rect, Result, Scaling, Session, Value, Vec2, bad, + bool_or, check_numbers, check_switches, duplicate_in, effects, f64_or, f64_req, ids_param, json, matrix_param, ok, outermost, point_param, selected_roots_to_transform, }; @@ -358,26 +358,52 @@ pub(super) fn scale(s: &mut Session, p: &Value) -> Result { if sx == 0.0 || sy == 0.0 { return Err(bad("object.scale", "scale must be non-zero")); } - apply_transform(s, "Scale", ids, about(o, Affine::scale_non_uniform(sx, sy)), p, true) + let frame = basis_frame(s, p, "object.scale", &ids)?; + apply_transform(s, "Scale", ids, about(o, frame.conjugate(Affine::scale_non_uniform(sx, sy))), p, true) +} + +/// The frame that `object.scale` and a named `object.reflect` act in, from the `basis` param: +/// `"box"` (the default) is the bounding box of `ids`, turned with rotated objects until Reset +/// Bounding Box ([`Document::bbox_angle`]); `"page"` is the page's axes. Anchors selected with +/// Direct Selection (with no copy and Transform Objects on) transform along the page's axes, as +/// their own box does. The basis joins the journal entry, so a replay acts in the same frame. A +/// saved step without `basis` ([`Session::replay`]) was recorded before Scale and Reflect followed +/// the box, so it acts along the page's axes, as it did when it was recorded. +fn basis_frame(s: &mut Session, p: &Value, cmd: &str, ids: &[NodeId]) -> Result { + let basis = choice(p, cmd, "basis", &["box", "page"])?; + let page = basis == Some("page") || (basis.is_none() && s.replaying()); + let st = s.doc()?; + let anchors = !bool_or(p, "copy", false) + && transforms_objects(p) + && effects::preview::anchor_bounds_with(&st.selection, &st.doc, s.bounds_measure()).is_some(); + let angle = if page || anchors { 0.0 } else { st.doc.bbox_angle(ids) }; + s.note_journal("basis", json!(if page { "page" } else { "box" })); + Ok(OrientedBox { angle, rect: Rect::ZERO }) } pub(super) fn reflect(s: &mut Session, p: &Value) -> Result { check_switches(p, &TRANSFORM_SWITCHES, "object.reflect")?; let ids = selected_roots_to_transform(s)?; let o = origin_of(s, p, &ids)?; - let m = match p.get("axis") { - Some(Value::String(a)) if a == "horizontal" => Affine::scale_non_uniform(1.0, -1.0), - Some(Value::Number(n)) => { - let t = -n.as_f64().unwrap_or(90.0).to_radians(); - Affine::rotate(t) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-t) - } - None | Some(Value::Null) => Affine::scale_non_uniform(-1.0, 1.0), - Some(Value::String(a)) if a == "vertical" => Affine::scale_non_uniform(-1.0, 1.0), + // A named axis is the bounding box's own (see [`basis_frame`]); an angle is a line on the page. + let named = match p.get("axis") { + Some(Value::String(a)) if a == "horizontal" => Some(Affine::scale_non_uniform(1.0, -1.0)), + Some(Value::Number(_)) => None, + None | Some(Value::Null) => Some(Affine::scale_non_uniform(-1.0, 1.0)), + Some(Value::String(a)) if a == "vertical" => Some(Affine::scale_non_uniform(-1.0, 1.0)), Some(v) => { let given = v.as_str().map_or_else(|| v.to_string(), str::to_string); return Err(bad("object.reflect", format!("axis must be \"vertical\", \"horizontal\" or an angle in degrees, not `{given}`"))); } }; + let m = match named { + Some(m) => basis_frame(s, p, "object.reflect", &ids)?.conjugate(m), + None => { + choice(p, "object.reflect", "basis", &["box", "page"])?; + let t = -p.get("axis").and_then(Value::as_f64).unwrap_or(90.0).to_radians(); + Affine::rotate(t) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-t) + } + }; apply_transform(s, "Reflect", ids, about(o, m), p, true) } diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 736358ff9b..db1edc98e9 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -424,9 +424,9 @@ fn check_effect_types(id: &str, effect: &str, params: &Value) -> Result<()> { /// A value of the type effect `effect` reads for `key`, a parameter its params description names /// without a dialog default: the earlier 3D Perspective and light (`perspective`, `lightAzimuth`, /// `lightElevation`) and Scribble `variation` are numbers, an Outline Stroke's `width` is a number -/// and a Gaussian Blur's `linear` is true or false. A Glass or Texturizer `textureImage`, an SVG -/// filter's `name` and `xml`, a Warp's `version` and the `style` of Crop Marks have checks of their -/// own; they aren't listed. +/// and a Gaussian Blur's `linear` is true or false. A Glass, Texturizer or Rough Pastels +/// `textureImage`, an SVG filter's `name` and `xml`, a Warp's `version` and the `style` of Crop +/// Marks have checks of their own; they aren't listed. pub(crate) fn undefaulted_type(effect: &str, key: &str) -> Option { use vectorcraft_render::effects; let number = || Some(Value::from(0.0)); diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index dc95f925b6..8c9a19cb8d 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark, or without one by dataset.import's rule: an even length, at least half of its two-byte units holding a NUL byte, the byte order that reads a line break, valid UTF-16 with no NUL character; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark, or without one by dataset.import's rule: an even length, at least half of its two-byte units holding a NUL byte, the byte order that reads a line break, valid UTF-16 with no NUL character; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file or PSD/PSB file placed in a clip group, which the Links commands take}. A PSD or PSB file that names a clipping path places in a clip group of that path (even-odd), as does its merged image in document.open. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), @@ -113,7 +113,9 @@ pub fn specs() -> Vec { /// A file's art at its natural size, before it joins a document. enum Art { - Image(RasterImage), + /// An image, with the clipping path its file names, in its pixel space (a PSD or PSB file's, + /// [`fileio::psdread::clipping_path`]). + Image(RasterImage, Option), /// A text file's, an RTF file's or a Word document's text, placed as area type filling the /// natural box. Text(text::Story), @@ -241,7 +243,8 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result(nodes: impl Iterator>) -> Vec { /// The art as one object of `d` (its resources added), at its natural size and position. fn build(d: &mut Document, l: Loaded, link: Option) -> Node { let mut node = match l.art { - Art::Image(img) => { + Art::Image(img, clip) => { let (sx, sy) = pt_per_px(img.ppi); super::links::store_image(d, &img.key, img.blob, link.is_some()); let im = ImageObject { @@ -423,7 +426,8 @@ fn build(d: &mut Document, l: Loaded, link: Option) -> Node { placement: Default::default(), pixels: None, }; - Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))) + let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); + fileio::file_clipped(d, image, clip) } Art::Dcs(a) => { let a = *a; @@ -485,9 +489,23 @@ fn at_param(p: &Value) -> Result> { /// The transform that puts new art with the natural box `natural` where `old` is: an image's scale /// and rotation relative to its 100% size (its resolution's) and its centre are kept (with -/// `image_xf`); other objects keep their centre. -fn replace_xf(d: &Document, old: &Node, natural: Rect, image_xf: bool) -> Affine { - if image_xf && let NodeKind::Image(im) = &old.kind { +/// `image_xf`), also for an image alone in a clip group when the new art is an image clipped by +/// its file's clipping path (`clipped`); other objects keep their centre. +fn replace_xf(d: &Document, old: &Node, natural: Rect, image_xf: bool, clipped: bool) -> Affine { + // An image alone in a clip group (as a PSD or PSB file's clipping path places it, + // [`fileio::file_clipped`]) is measured as the image when the new art brings its own clip + // group; a DCS file's plates are not. + let image = match &old.kind { + NodeKind::Group { children, clip: true } if clipped => match &children[..] { + [path, image] if matches!(path.kind, NodeKind::Path { clipping: true, .. }) => match &image.kind { + NodeKind::Image(im) if d.images.get(&im.key).is_none_or(|b| b.separated().is_none()) => image, + _ => old, + }, + _ => old, + }, + _ => old, + }; + if image_xf && let NodeKind::Image(im) = &image.kind { let (sx, sy) = pt_per_px(d.images.get(&im.key).and_then(|b| fileio::ppi::resolution(&b.bytes))); let full = Affine::scale_non_uniform(sx, sy); let centre = full * Point::new(im.width as f64 / 2.0, im.height as f64 / 2.0); @@ -541,7 +559,7 @@ impl PlaceJob { let mut loaded = load(&self.params, PLACE, self.target, true)?; let linked = loaded.link.is_some() && bool_or(&self.params, "link", true); match &mut loaded.art { - Art::Image(img) => { + Art::Image(img, _) => { let unread = super::links::kept_unread(&img.blob.bytes); img.blob = super::links::readable_and_kept(img.blob.clone(), unread, linked)?; } @@ -657,7 +675,7 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result { } let natural = loaded.natural; let m = match (old_node, rect) { - (Some(o), _) => replace_xf(&st.doc, o, natural, !is_text), + (Some(o), _) => replace_xf(&st.doc, o, natural, !is_text, matches!(loaded.art, Art::Image(_, Some(_)))), (None, Some(r)) => fit(natural, r), // Its artboard's bottom-left corner on the target's. (None, None) if let (Some(src), Some(to)) = (loaded.board, target) => Affine::translate((to.x0 - src.x0, to.y1 - src.y1)), @@ -671,7 +689,7 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result { let link = loaded.link.take().filter(|_| bool_or(p, "link", true)); let linked = link.is_some(); let (name, format, mut warnings) = (loaded.name.clone(), loaded.format.id, std::mem::take(&mut loaded.warnings)); - let dcs_clipped = matches!(&loaded.art, Art::Dcs(a) if a.clip.is_some()); + let file_clipped = matches!(&loaded.art, Art::Dcs(a) if a.clip.is_some()) || matches!(&loaded.art, Art::Image(_, Some(_))); if let Art::Dcs(a) = &loaded.art { warnings.extend(dcs::swatch_notes(&st.doc, &a.spots)); } @@ -697,8 +715,9 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result { Ok((id, size)) })?; let mut out = json!({ "ids": [id.0], "name": name, "format": format, "linked": linked, "width": size.width(), "height": size.height(), "warnings": warnings }); - // A DCS file clipped by its clipping path: its image, which the Links commands take. - if dcs_clipped { + // A DCS, PSD or PSB file clipped by its clipping path: its image, which the Links commands + // take. + if file_clipped { let image = s.doc()?.doc.node(id).and_then(|n| match &n.kind { NodeKind::Group { children, clip: true } => children.get(1).filter(|c| matches!(c.kind, NodeKind::Image(_))).map(|c| c.id), _ => None, @@ -810,7 +829,7 @@ fn read_queue(doc: u64, p: Value, queued: Vec) -> Result { let checked = load(&q, QUEUE, None, false).and_then(|mut l| { let linked = l.link.is_some() && bool_or(&q, "link", true); match &mut l.art { - Art::Image(img) => { + Art::Image(img, _) => { let unread = super::links::kept_unread(&img.blob.bytes); let check = if thumbnail.is_some() { super::links::readable_and_kept } else { super::links::readable }; img.blob = check(img.blob.clone(), unread, linked)?; @@ -932,7 +951,7 @@ pub fn read_info(p: &Value) -> Result { "height": natural.height(), "warnings": std::mem::take(&mut l.warnings), }); - if let Art::Image(img) = &l.art { + if let Art::Image(img, _) = &l.art { let (x, y) = img.ppi.unwrap_or((72.0, 72.0)); out["pixelWidth"] = json!(img.width); out["pixelHeight"] = json!(img.height); diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 867f256b4c..48a6649d77 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -538,8 +538,8 @@ pub(crate) fn run_command(s: &mut Session, id: &str, p: &Value) -> Result /// live-effect plug-in) with parameters it takes, filled in to their defaults. A record the object /// already had stays as it was. An object takes one 3D effect (its fills' and strokes' own /// counted), which must fit it, and the `textureImage` of an effect with Load Texture (Glass, -/// Texturizer) comes as the effect stores it, never as a file to read: a plug-in can't have the -/// host read one. +/// Texturizer, Rough Pastels) comes as the effect stores it, never as a file to read: a plug-in +/// can't have the host read one. fn admit_effects(fx: &[Effect], n: &Node) -> std::result::Result, String> { admit_effects_for("plugin.run", fx, n) } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index ee843df8dd..7aed215d3d 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), warichu?: bool (Character panel menu › Warichu, with Show East Asian Options: the characters are set at warichuScale percent of their size and stacked on warichuLines lines inside their line, one above the other in horizontal type and side by side in vertical type, the first line above or to the right; a stretch of them is one block with the settings of its first character that stays whole on one line and may run past its end; type on a path sets them at their own size), warichuLines?: 1..6 (default 2), warichuGap?: pt 0..100 (the space between the lines, default 0), warichuScale?: % 10..100 (default 50), warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Warichu Settings: how the lines line up along the block, which is as long as its longest line; the justify values spread every line but the last, which is set left, centered or right, or spread with justifyAll; auto, the default, sets them as justifyLeft; left and right are top and bottom in vertical type; none of them turns warichu on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options: the share of the blank space on each side of each character's ink, inside its own width, that is taken away in horizontal type, the glyph keeping its shape; 100 sets the ink edge to edge; a character without ink, such as a space, keeps its width; a ligature or a letter with its marks takes the value of its first character; tracking, kerning and Insert Aki are added after it, and justification keeps it; vertical type keeps the characters' spacing; 0 by default; a value that is not a number or is outside the range is refused), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), glyphForm?: \"traditional\"|\"expert\"|\"jis04\"|\"jis90\"|\"jis78\"|\"jis83\"|\"fullWidth\"|\"halfWidth\"|\"thirdWidth\"|\"quarterWidth\"|\"default\" (Glyphs panel menu, with Show East Asian Options: the characters are drawn in that form of the font, the OpenType feature trad, expt, jp04, jp90, jp78, jp83, fwid, hwid, twid or qwid, one form at a time; a character the font has no such glyph for keeps its own; \"default\", Revert To Default Forms, draws the font's default glyphs; another value is refused), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; \"hkna\" is H or V Style, kana in the font's forms for the writing direction, shaped as `vkna` in vertical type, and \"ital\" is Roman Italics, half-width alphanumerics in the font's italic forms, both on or off only; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), warichu?: bool (Character panel menu › Warichu, with Show East Asian Options: the characters are set at warichuScale percent of their size and stacked on warichuLines lines inside their line, one above the other in horizontal type and side by side in vertical type, the first line above or to the right; a stretch of them is one block with the settings of its first character that stays whole on one line and may run past its end; type on a path sets them at their own size), warichuLines?: 1..6 (default 2), warichuGap?: pt 0..100 (the space between the lines, default 0), warichuScale?: % 10..100 (default 50), warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Warichu Settings: how the lines line up along the block, which is as long as its longest line; the justify values spread every line but the last, which is set left, centered or right, or spread with justifyAll; auto, the default, sets them as justifyLeft; left and right are top and bottom in vertical type; none of them turns warichu on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options: the share of the blank space on each side of each character's ink, inside its own width, that is taken away in horizontal type, the glyph keeping its shape; 100 sets the ink edge to edge; a character without ink, such as a space, keeps its width; a ligature or a letter with its marks takes the value of its first character; tracking, kerning and Insert Aki are added after it, and justification keeps it; vertical type keeps the characters' spacing; 0 by default; a value that is not a number or is outside the range is refused), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), glyphForm?: \"traditional\"|\"expert\"|\"jis04\"|\"jis90\"|\"jis78\"|\"jis83\"|\"fullWidth\"|\"halfWidth\"|\"thirdWidth\"|\"quarterWidth\"|\"default\" (Glyphs panel menu, with Show East Asian Options: the characters are drawn in that form of the font, the OpenType feature trad, expt, jp04, jp90, jp78, jp83, fwid, hwid, twid or qwid, one form at a time; a character the font has no such glyph for keeps its own; \"default\", Revert To Default Forms, draws the font's default glyphs; another value is refused), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -659,7 +659,7 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result> return Err(bad( cmd, format!( - "unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {}, as `salt=2`)", + "unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {}, as `salt=2`)", vectorcraft_text::MAX_FEATURE_VALUE ), )); diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 31bcad4671..2cc7c3c1d3 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -140,7 +140,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text; no undo step when no character, paragraph or graph label style changes) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; \"hkna\" is H or V Style, kana in the font's forms for the writing direction, shaped as `vkna` in vertical type, and \"ital\" is Roman Italics, half-width alphanumerics in the font's italic forms, both on or off only), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text; no undo step when no character, paragraph or graph label style changes) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_style ), diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index ca9e4dc2ed..e0e5d8a926 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -1589,6 +1589,40 @@ fn proportional_metrics_is_a_character_attribute_saved_only_when_on() { assert!(!serde_json::from_value::(old).unwrap().proportional_metrics); } +/// H or V Style (`hkna`) and Roman Italics (`ital`, OpenType panel menu, East Asian options) are +/// feature tags of the characters: set on a range and turned off again, kept by a character style +/// and in a saved document, on or off only (`hkna=2` is refused and changes nothing). +#[test] +fn kana_style_and_roman_italics_are_character_features() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "かなAB"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.features.clone())).collect::>(), + _ => panic!("text"), + }; + // か and な are 3 bytes each. + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 6, "features": ["hkna"]})).unwrap(); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 6, "end": 8, "featureChanges": ["ital"]})).unwrap(); + assert_eq!(runs(&s), [("かな".to_string(), vec!["hkna".to_string()]), ("AB".to_string(), vec!["ital".to_string()])]); + let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &saved.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!(t.runs.iter().map(|r| r.style.features.clone()).collect::>(), [vec!["hkna".to_string()], vec!["ital".to_string()]]); + let before = runs(&s); + for bad in [json!({"id": id, "features": ["hkna=2"]}), json!({"id": id, "featureChanges": ["ital=1"]})] { + let e = s.execute("text.setRangeStyle", &bad).unwrap_err().to_string(); + assert!(e.contains("unknown OpenType feature"), "{e}"); + } + assert_eq!(runs(&s), before); + s.execute("text.setRangeStyle", &json!({"id": id, "featureChanges": ["-hkna", "-ital"]})).unwrap(); + assert!(runs(&s).iter().all(|(_, f)| f.is_empty()), "{:?}", runs(&s)); + s.execute("charStyle.new", &json!({"name": "Kana", "attrs": {"features": ["hkna", "ital"]}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Kana", "id": id})).unwrap(); + assert!(runs(&s).iter().all(|(_, f)| *f == ["hkna", "ital"]), "{:?}", runs(&s)); + let e = s.execute("charStyle.new", &json!({"name": "Bad", "attrs": {"features": ["ital=2"]}})).unwrap_err().to_string(); + assert!(e.contains("unknown OpenType feature `ital=2`"), "{e}"); +} + /// Tate-chu-yoko (Character panel menu, East Asian options): a character attribute, saved only /// when on. Documents written before it read as off. #[test] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3b54a532ef..f89cc98ff8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1293,7 +1293,12 @@ fn a_transform_that_moves_nothing_records_no_undo_step() { for (id, p) in calls { assert_eq!(s.execute(id, &p).unwrap(), json!({"ids": [a.0]}), "{id} {p}"); assert_eq!(history(&s), before, "{id} {p}"); - assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p)); + // The journal adds the frame a scale acted in, so a replay acts in the same one. + let mut recorded = p; + if id == "object.scale" { + recorded["basis"] = json!("box"); + } + assert_eq!(s.journal.last().unwrap(), &(id.to_string(), recorded)); } assert_eq!( s.doc().unwrap().last_transform, diff --git a/crates/engine/src/tests/bboxrotate.rs b/crates/engine/src/tests/bboxrotate.rs index 6dc1c9b410..dfabb77236 100644 --- a/crates/engine/src/tests/bboxrotate.rs +++ b/crates/engine/src/tests/bboxrotate.rs @@ -666,7 +666,7 @@ fn expanding_a_reflected_symbol_instance_keeps_its_box() { for reset in [false, true] { let (mut s, inst) = turned_instance(0.0); s.execute("object.rotate", &json!({"angle": 150})).unwrap(); - s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap(); + s.execute("object.reflect", &json!({"axis": "horizontal", "basis": "page"})).unwrap(); assert!(near(angle(&s, inst), -170.0), "{}", angle(&s, inst)); if reset { s.execute("object.resetBoundingBox", &json!({})).unwrap(); @@ -719,10 +719,164 @@ fn page_flips_keep_an_upright_box_upright() { s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap(); assert_eq!(angle(&s, a), 0.0); s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + // A named axis is the turned box's own: the box keeps its angle. s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap(); + assert!(near(angle(&s, a), 30.0)); + s.execute("object.reflect", &json!({"axis": "vertical", "basis": "page"})).unwrap(); assert!(near(angle(&s, a), -30.0)); } +fn same_matrix(a: Affine, b: Affine) -> bool { + a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| near(*x, y)) +} + +/// After a rotation, Scale and a named Reflect act along the turned bounding box until Reset +/// Bounding Box; `basis: "page"` keeps the page's axes, and Transform Again repeats the matrix the +/// transform applied on the page. +#[test] +fn scale_and_reflect_follow_the_turned_box() { + let mut s = session(); + let a = turned_rect(&mut s); + let page = |s: &Session, ids: &[NodeId]| s.doc().unwrap().doc.bounds_of(ids, false).unwrap(); + let c = Point::new(200.0, 200.0); + let frame = OrientedBox { angle: 45.0, rect: Rect::ZERO }; + s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap(); + let b = selection_box(&s); + assert!(near(b.angle, 45.0) && near(angle(&s, a), 45.0), "{b:?}"); + assert!(near(b.rect.width(), 200.0) && near(b.rect.height(), 50.0), "{b:?}"); + assert!(b.center().distance(c) < 1e-6, "{b:?}"); + let want = Affine::translate(c.to_vec2()) * frame.conjugate(Affine::scale_non_uniform(2.0, 1.0)) * Affine::translate(-c.to_vec2()); + let Some(LastTransform::Matrix(m, _)) = s.doc().unwrap().last_transform.clone() else { panic!("no transform to repeat") }; + assert!(same_matrix(m, want), "{m:?}"); + // The recorded step names its basis, so a replay acts in the same frame. + let steps = s.journal_for_action(0); + assert!(steps.iter().any(|(id, p)| id == "object.scale" && p["basis"] == "box"), "{steps:?}"); + + // Reflected across the box's vertical axis through its left side: the box keeps its angle and + // size, and its centre moves a width to the left along the box. + let left = b.reference_point(3); + s.execute("object.reflect", &json!({"axis": "vertical", "origin": [left.x, left.y]})).unwrap(); + let r = selection_box(&s); + let moved = c + (frame.to_doc() * Point::new(-200.0, 0.0)).to_vec2(); + assert!(near(r.angle, 45.0) && near(r.rect.width(), 200.0) && near(r.rect.height(), 50.0), "{r:?}"); + assert!(r.center().distance(moved) < 1e-6, "{r:?}"); + // Across its horizontal axis through its centre: the box stays where it is. + s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap(); + assert!(selection_box(&s).center().distance(moved) < 1e-6 && near(angle(&s, a), 45.0)); + + // Unknown bases are errors and change nothing. + let n = undo_len(&s); + assert!(s.execute("object.scale", &json!({"sx": 50, "basis": "turned"})).is_err()); + assert!(s.execute("object.reflect", &json!({"axis": "vertical", "basis": 1})).is_err()); + assert!(s.execute("object.reflect", &json!({"axis": 30, "basis": "turned"})).is_err()); + assert_eq!(undo_len(&s), n); + + // On the page's axes, the box no longer stands at 45°. + s.execute("object.scale", &json!({"sx": 50, "sy": 100, "basis": "page"})).unwrap(); + assert!(!near(angle(&s, a), 45.0)); + // Transform Again repeats the page matrix after Reset Bounding Box too. + s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap(); + let Some(LastTransform::Matrix(m, _)) = s.doc().unwrap().last_transform.clone() else { panic!("no transform to repeat") }; + s.execute("object.resetBoundingBox", &json!({})).unwrap(); + let before = s.doc().unwrap().doc.node(a).unwrap().clone(); + s.execute("object.transformAgain", &json!({})).unwrap(); + let mut want = before; + want.transform(m, false); + let (got, want) = (page(&s, &[a]), want.geometric_bounds_with(&s.doc().unwrap().doc.symbol_boxes()).unwrap()); + assert!([got.x0 - want.x0, got.y0 - want.y0, got.x1 - want.x1, got.y1 - want.y1].iter().all(|d| d.abs() < 1e-6), "{got:?} {want:?}"); +} + +/// A saved step without `basis` was recorded before Scale and Reflect followed the turned box, so a +/// replay of it acts along the page's axes, as it did then, and its new journal entry says so. A +/// step recorded now names its basis and replays along the box. A call without `basis` that is not +/// a replay acts along the box. +#[test] +fn saved_steps_without_a_basis_replay_on_the_page() { + // The turned rectangle after `run` runs on it: its page bounds and its box's angle. + let after = |run: &dyn Fn(&mut Session)| { + let mut s = session(); + let a = turned_rect(&mut s); + run(&mut s); + let st = (s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(), angle(&s, a)); + (st, s.journal.last().unwrap().clone()) + }; + let same = |(r, a): (Rect, f64), (q, b): (Rect, f64)| [r.x0 - q.x0, r.y0 - q.y0, r.x1 - q.x1, r.y1 - q.y1, a - b].iter().all(|d| d.abs() < 1e-6); + for (id, p) in [("object.scale", json!({"sx": 200, "sy": 100})), ("object.reflect", json!({"axis": "vertical"}))] { + let mut on_page = p.clone(); + on_page["basis"] = json!("page"); + let (page, _) = after(&|s| { + s.execute(id, &on_page).unwrap(); + }); + let (old, (_, noted)) = after(&|s| { + s.replay(id, &p).unwrap(); + }); + assert!(same(old, page), "{id}: {old:?} {page:?}"); + assert_eq!(noted["basis"], "page", "{id}"); + + let (boxed, (recorded_id, recorded)) = after(&|s| { + s.execute(id, &p).unwrap(); + }); + assert!(!same(boxed, page) && near(boxed.1, 45.0), "{id}: {boxed:?}"); + assert_eq!(recorded["basis"], "box", "{id}"); + let (replayed, _) = after(&|s| { + s.replay(&recorded_id, &recorded).unwrap(); + }); + assert!(same(replayed, boxed), "{id}: {replayed:?} {boxed:?}"); + } +} + +/// Once Reset Bounding Box squares the box, and for objects standing at different angles, Scale +/// acts along the page's axes. +#[test] +fn scale_follows_the_page_after_a_reset_and_for_mixed_angles() { + let mut s = session(); + let a = turned_rect(&mut s); + s.execute("object.resetBoundingBox", &json!({})).unwrap(); + let page = |s: &Session, ids: &[NodeId]| s.doc().unwrap().doc.bounds_of(ids, false).unwrap(); + let w = page(&s, &[a]); + s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap(); + let r = page(&s, &[a]); + assert!(near(r.width(), 2.0 * w.width()) && near(r.height(), w.height()), "{w:?} {r:?}"); + assert_eq!(angle(&s, a), 0.0); + // A turned rectangle with an upright one: the box is square to the page. + let b = turned_rect(&mut s); + let u = rect(&mut s, 400.0, 100.0, 50.0, 50.0); + sel(&mut s, &[b, u]); + let w = page(&s, &[b, u]); + s.execute("object.scale", &json!({"sx": 100, "sy": 200})).unwrap(); + let r = page(&s, &[b, u]); + assert!(near(r.width(), w.width()) && near(r.height(), 2.0 * w.height()), "{w:?} {r:?}"); +} + +/// With anchors selected, Scale and a named Reflect move the anchors along the page's axes, but +/// with Transform Objects off they move the whole objects' pattern tiles, along the turned box. +#[test] +fn pattern_only_transforms_of_selected_anchors_follow_the_turned_box() { + let c = Point::new(200.0, 200.0); + let frame = OrientedBox { angle: 45.0, rect: Rect::ZERO }; + let about = |m: Affine| Affine::translate(c.to_vec2()) * m * Affine::translate(-c.to_vec2()); + let last = |s: &Session| match s.doc().unwrap().last_transform.clone() { + Some(LastTransform::Matrix(m, _)) => m, + other => panic!("no matrix to repeat: {other:?}"), + }; + let scale = Affine::scale_non_uniform(2.0, 1.0); + let flip = Affine::scale_non_uniform(-1.0, 1.0); + for (cmd, p, m) in [("object.scale", json!({"sx": 200, "sy": 100}), scale), ("object.reflect", json!({"axis": "vertical"}), flip)] { + for objects in [false, true] { + let mut s = session(); + let a = turned_rect(&mut s); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + let mut p = p.clone(); + p["origin"] = json!([c.x, c.y]); + p["objects"] = json!(objects); + p["patterns"] = json!(true); + s.execute(cmd, &p).unwrap(); + let want = if objects { about(m) } else { about(frame.conjugate(m)) }; + assert!(same_matrix(last(&s), want), "{cmd} objects {objects}: {:?}", last(&s)); + } + } +} + #[test] fn groups_type_and_multiple_selections_keep_their_angle() { let mut s = session(); diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 8fd5a3aa45..243dce52e8 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3,7 +3,8 @@ use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::appearance::{Effect, FillLayer, StrokeLayer}; use vectorcraft_doc::{AppearanceItem, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind}; use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect}; use vectorcraft_testkit::pdf::{PdfPage, pdf}; @@ -2684,6 +2685,401 @@ fn live_paint_make_and_expand_keep_abutting_compound_colors() { check_colors(&node(&s, group)); } +/// Gives `id` the appearance rows `items` (paint order) and returns them. +fn set_rows(s: &mut Session, id: NodeId, items: Vec) -> Vec { + s.edit("t", |d, _| { + d.node_mut(id).unwrap().appearance = vectorcraft_doc::Appearance { items: items.clone(), ..Default::default() }.into(); + Ok(()) + }) + .unwrap(); + items +} + +/// A fill row of `p`, changed by `f`. +fn fill_row(p: Paint, f: impl FnOnce(&mut FillLayer)) -> AppearanceItem { + let mut l = FillLayer::new(p); + f(&mut l); + AppearanceItem::Fill(l) +} + +/// A stroke row of `p` `width` wide, changed by `f`. +fn stroke_row(p: Paint, width: f64, f: impl FnOnce(&mut StrokeLayer)) -> AppearanceItem { + let mut l = StrokeLayer::new(p, width); + f(&mut l); + AppearanceItem::Stroke(l) +} + +/// The faces and edges of Live Paint group `g` whose bounds lie within x `x0`..`x1`. +fn parts_within(s: &Session, g: NodeId, x0: f64, x1: f64) -> (Vec, Vec) { + let n = node(s, g); + let within = |c: &&Arc| c.geometric_bounds().is_some_and(|b| b.x0 >= x0 - 1e-6 && b.x1 <= x1 + 1e-6); + let faces = vectorcraft_tools::builder::faces(&n).into_iter().filter(within).map(|c| (**c).clone()).collect(); + let edges = vectorcraft_tools::builder::edges(&n).into_iter().filter(within).map(|c| (**c).clone()).collect(); + (faces, edges) +} + +/// Make gives each edge every stroke row of its source that draws, a lower one under an +/// unpainted or hidden top row included, with the rows' settings and the source's opacity and +/// blending mode; rows that draw nothing stay only with the stored source, which Release returns. +#[test] +fn live_paint_make_keeps_lower_drawn_stroke_rows() { + let mut s = session(); + let (a, b) = two_at(&mut s, 200.0); + let black = Paint::solid(Color::BLACK); + let green = Paint::solid(Color::rgb(0.0, 1.0, 0.0)); + let thick = stroke_row(black.clone(), 3.0, |l| { + l.opacity = 0.5; + l.blend = BlendMode::Multiply; + l.cap = LineCap::Round; + }); + // A: the top stroke has no paint. + let a_rows = + set_rows(&mut s, a, vec![fill_row(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), |_| {}), thick.clone(), stroke_row(Paint::None, 1.0, |_| {})]); + // B: the top stroke is hidden; a zero-weight and a transparent stroke draw nothing either. + set_rows( + &mut s, + b, + vec![ + stroke_row(green.clone(), 2.0, |_| {}), + stroke_row(black.clone(), 0.0, |_| {}), + stroke_row(black, 2.0, |l| l.opacity = 0.0), + stroke_row(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 5.0, |l| l.visible = false), + ], + ); + s.edit("t", |d, _| { + let n = d.node_mut(a).unwrap(); + n.opacity = 0.75; + n.blend = BlendMode::Screen; + Ok(()) + }) + .unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let (_, a_edges) = parts_within(&s, g, 0.0, 100.0); + let (_, b_edges) = parts_within(&s, g, 200.0, 300.0); + assert!(!a_edges.is_empty() && !b_edges.is_empty()); + for e in &a_edges { + assert_eq!(e.appearance.items, vec![thick.clone()], "A's edge takes the stroke that draws"); + assert_eq!((e.opacity, e.blend), (0.75, BlendMode::Screen)); + } + for e in &b_edges { + assert_eq!(e.appearance.items, vec![stroke_row(green.clone(), 2.0, |_| {})], "B's edge takes only its green stroke"); + assert_eq!((e.opacity, e.blend), (1.0, BlendMode::Normal)); + } + // Release returns the sources with every row they had. + let released = ids(&s.execute("livePaint.release", &json!({})).unwrap()); + assert!(released.contains(&a)); + assert_eq!(node(&s, a).appearance.items, a_rows); +} + +/// When Make cuts a dashed stroke with arrowheads, a width profile and a brush into edges, each +/// edge's dashes go on from where it starts along the path, the arrowheads stay at the path's own +/// ends, and the profile and brush, which spread over the whole path, are left off the pieces. +#[test] +fn live_paint_make_fits_dashes_and_arrowheads_to_each_edge() { + use vectorcraft_doc::{Arrowhead, Dash}; + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let l = line(&mut s, (-20.0, 50.0), (120.0, 50.0)); + let dash = Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: true }; + let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| { + st.dash = Some(dash.clone()); + st.start_arrow = Some(Arrowhead::Triangle); + st.end_arrow = Some(Arrowhead::Circle); + st.brush = Some("Charcoal".into()); + }); + let rows = set_rows(&mut s, l, vec![row]); + s.execute("select.set", &json!({"ids": [a.0, l.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let gn = node(&s, g); + let mut pieces: Vec<(f64, f64, vectorcraft_doc::StrokeLayer)> = vectorcraft_tools::builder::edges(&gn) + .iter() + .filter_map(|e| { + let st = e.appearance.stroke().filter(|st| st.dash.is_some())?.clone(); + let sp = e.path_data()?.subpaths.first()?; + Some((sp.anchors.first()?.p.x, sp.anchors.last()?.p.x, st)) + }) + .collect(); + pieces.sort_by(|p, q| p.0.total_cmp(&q.0)); + let ends: Vec<(f64, f64)> = pieces.iter().map(|p| ((p.0 * 1e3).round() / 1e3, (p.1 * 1e3).round() / 1e3)).collect(); + assert_eq!(ends, vec![(-20.0, 0.0), (0.0, 100.0), (100.0, 120.0)], "each piece runs the way the line does"); + let offsets: Vec = pieces.iter().map(|p| (p.2.dash.as_ref().unwrap().offset * 1e3).round() / 1e3).collect(); + assert_eq!(offsets, vec![2.0, 22.0, 122.0], "the dashes go on along the line"); + assert!(pieces.iter().all(|p| !p.2.dash.as_ref().unwrap().align_corners && p.2.brush.is_none())); + let arrows: Vec<_> = pieces.iter().map(|p| (p.2.start_arrow.clone(), p.2.end_arrow.clone())).collect(); + assert_eq!(arrows, vec![(Some(Arrowhead::Triangle), None), (None, None), (None, Some(Arrowhead::Circle))]); + // A path that crosses itself: the pieces that start at the crossing take their place on the + // path from where they run, not from the first time the path passes that point. + let mut s3 = session(); + let anchors = [(0.0, 0.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)].map(|(x, y)| json!({"x": x, "y": y})); + let z = NodeId(s3.execute("path.create", &json!({"anchors": anchors, "closed": false})).unwrap()["id"].as_u64().unwrap()); + set_rows(&mut s3, z, rows.clone()); + s3.execute("select.set", &json!({"ids": [z.0]})).unwrap(); + let g3 = NodeId(s3.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let mut cut: Vec<(f64, Option, Option)> = vectorcraft_tools::builder::edges(&node(&s3, g3)) + .iter() + .filter_map(|e| { + let st = e.appearance.stroke()?; + Some(((st.dash.as_ref()?.offset * 1e3).round() / 1e3, st.start_arrow.clone(), st.end_arrow.clone())) + }) + .collect(); + cut.sort_by(|p, q| p.0.total_cmp(&q.0)); + let d = 2.0 + 50.0 * 2f64.sqrt(); + let r = |v: f64| (v * 1e3).round() / 1e3; + assert_eq!( + cut, + vec![(2.0, Some(Arrowhead::Triangle), None), (r(d), None, None), (r(d + 100.0 + 100.0 * 2f64.sqrt()), None, Some(Arrowhead::Circle))] + ); + // An uncut path keeps all of it. + let mut s2 = session(); + let l2 = line(&mut s2, (0.0, 0.0), (50.0, 0.0)); + set_rows(&mut s2, l2, rows.clone()); + s2.execute("select.set", &json!({"ids": [l2.0]})).unwrap(); + let g2 = NodeId(s2.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let edges: Vec<_> = vectorcraft_tools::builder::edges(&node(&s2, g2)).iter().map(|e| e.appearance.items.clone()).collect(); + assert_eq!(edges, vec![rows]); +} + +/// When Gap Options or Merge cuts an edge in two, each piece keeps the edge's dashes going on from +/// where it starts along the edge, an arrowhead only at the end of the edge it ends at, and no brush. +#[test] +fn live_paint_gap_options_and_merge_fit_an_edge_look_to_each_piece() { + use vectorcraft_doc::{Arrowhead, Dash}; + let mut s = session(); + let l = line(&mut s, (0.0, 50.0), (100.0, 50.0)); + let stub = line(&mut s, (50.0, 0.0), (50.0, 48.0)); + let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| { + st.dash = Some(Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: false }); + st.start_arrow = Some(Arrowhead::Triangle); + st.end_arrow = Some(Arrowhead::Circle); + st.brush = Some("Charcoal".into()); + }); + let rows = set_rows(&mut s, l, vec![row]); + s.execute("select.set", &json!({"ids": [l.0, stub.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let dashed = |s: &Session, g: NodeId| { + let mut out: Vec<(f64, f64, vectorcraft_doc::StrokeLayer)> = vectorcraft_tools::builder::edges(&node(s, g)) + .iter() + .filter_map(|e| { + let st = e.appearance.stroke().filter(|st| st.dash.is_some())?.clone(); + let sp = e.path_data()?.subpaths.first()?; + Some(((sp.anchors.first()?.p.x * 1e3).round() / 1e3, (sp.anchors.last()?.p.x * 1e3).round() / 1e3, st)) + }) + .collect(); + out.sort_by(|p, q| p.0.total_cmp(&q.0)); + out + }; + let before = dashed(&s, g); + assert_eq!(before.len(), 1, "nothing cuts the line yet"); + assert_eq!(vec![AppearanceItem::Stroke(before[0].2.clone())], rows); + let r = s.execute("livePaint.gapOptions", &json!({"id": g.0, "closeGaps": true})).unwrap(); + assert_eq!(r["closed"], json!(1)); + let pieces = dashed(&s, g); + let ends: Vec<(f64, f64)> = pieces.iter().map(|p| (p.0, p.1)).collect(); + assert_eq!(ends, vec![(0.0, 50.0), (50.0, 100.0)], "the line is cut where the gap closes"); + let offsets: Vec = pieces.iter().map(|p| (p.2.dash.as_ref().unwrap().offset * 1e3).round() / 1e3).collect(); + assert_eq!(offsets, vec![2.0, 52.0], "the dashes go on along the line"); + let arrows: Vec<_> = pieces.iter().map(|p| (p.2.start_arrow.clone(), p.2.end_arrow.clone())).collect(); + assert_eq!(arrows, vec![(Some(Arrowhead::Triangle), None), (None, Some(Arrowhead::Circle))]); + assert!(pieces.iter().all(|p| p.2.brush.is_none()), "a brush spreads over the whole line"); + // Merge: the piece of the edge that an added path cuts off, which takes the edge's look, gets + // it fitted to where it lies along the edge in the same way. + let mut s = session(); + let l = line(&mut s, (0.0, 50.0), (100.0, 50.0)); + set_rows(&mut s, l, rows.clone()); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let cut = line(&mut s, (25.0, 0.0), (25.0, 100.0)); + s.execute("livePaint.merge", &json!({"ids": [g.0, cut.0]})).unwrap(); + let pieces = dashed(&s, g); + let ends: Vec<(f64, f64)> = pieces.iter().map(|p| (p.0, p.1)).collect(); + assert_eq!(ends, vec![(0.0, 25.0), (25.0, 100.0)], "the added line cuts the edge"); + let offsets: Vec = pieces.iter().map(|p| (p.2.dash.as_ref().unwrap().offset * 1e3).round() / 1e3).collect(); + assert_eq!(offsets, vec![2.0, 27.0], "the dashes go on along the line"); + let arrows: Vec<_> = pieces.iter().map(|p| (p.2.start_arrow.clone(), p.2.end_arrow.clone())).collect(); + assert_eq!(arrows, vec![(Some(Arrowhead::Triangle), None), (None, Some(Arrowhead::Circle))]); + assert!(pieces.iter().all(|p| p.2.brush.is_none()), "a brush spreads over the whole line"); + // A repainted edge that Merge cuts keeps its paint on every piece. + let mut s = session(); + let l = line(&mut s, (0.0, 50.0), (100.0, 50.0)); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [50, 50], "color": "#ff0000", "width": 3})).unwrap(); + let cut = line(&mut s, (50.0, 0.0), (50.0, 100.0)); + s.execute("livePaint.merge", &json!({"ids": [g.0, cut.0]})).unwrap(); + let gn = node(&s, g); + let red = Paint::solid(Color::from_hex("#ff0000").unwrap()); + let mut on_line: Vec<(f64, bool)> = vectorcraft_tools::builder::edges(&gn) + .iter() + .filter_map(|e| { + let sp = e.path_data()?.subpaths.first()?; + let (a, z) = (sp.anchors.first()?.p, sp.anchors.last()?.p); + ((a.y - 50.0).abs() < 1e-6 && (z.y - 50.0).abs() < 1e-6) + .then(|| ((a.x.min(z.x) * 1e3).round() / 1e3, e.appearance.stroke().is_some_and(|st| st.paint == red))) + }) + .collect(); + on_line.sort_by(|p, q| p.0.total_cmp(&q.0)); + assert_eq!(on_line, vec![(0.0, true), (50.0, true)], "both pieces keep the edge's paint"); +} + +/// The edge Make builds from a closed path that nothing cuts starts at the anchor the path starts +/// at and runs the way the path does, whichever anchor that is, so its dashes and brush lie where +/// they do on the path, and it keeps the path's stroke row as it is. +#[test] +fn live_paint_make_starts_a_closed_edge_where_its_path_starts() { + use vectorcraft_doc::Dash; + let corners = [(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)]; + for clockwise in [true, false] { + for start in 0..4 { + let mut s = session(); + let mut pts: Vec<(f64, f64)> = (0..4).map(|k| corners[(k + start) % 4]).collect(); + if !clockwise { + pts.reverse(); + } + let anchors: Vec<_> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect(); + let z = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": true})).unwrap()["id"].as_u64().unwrap()); + let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| { + st.dash = Some(Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: false }); + st.brush = Some("Charcoal".into()); + }); + let rows = set_rows(&mut s, z, vec![row]); + s.execute("select.set", &json!({"ids": [z.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let gn = node(&s, g); + let edges = vectorcraft_tools::builder::edges(&gn); + let [e] = edges.as_slice() else { panic!("one edge, got {}", edges.len()) }; + let sp = e.path_data().unwrap().subpaths.first().unwrap().clone(); + let at: Vec<(f64, f64)> = sp.anchors.iter().map(|a| ((a.p.x * 1e3).round() / 1e3, (a.p.y * 1e3).round() / 1e3)).collect(); + assert!(sp.closed); + assert_eq!(at, pts, "clockwise {clockwise}, start {start}"); + assert_eq!(e.appearance.items, rows, "clockwise {clockwise}, start {start}"); + } + } + // A closed path that crosses itself is cut into loops, and the loop that holds the path's + // first anchor is still only part of the path: it keeps no brush. + let mut s = session(); + let anchors = [(0.0, 0.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)].map(|(x, y)| json!({"x": x, "y": y})); + let z = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": true})).unwrap()["id"].as_u64().unwrap()); + let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| { + st.dash = Some(Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: false }); + st.brush = Some("Charcoal".into()); + }); + set_rows(&mut s, z, vec![row]); + s.execute("select.set", &json!({"ids": [z.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let gn = node(&s, g); + let edges = vectorcraft_tools::builder::edges(&gn); + assert!(edges.len() > 1, "the crossing cuts the path, got {} edge", edges.len()); + assert!(edges.iter().all(|e| e.appearance.stroke().is_some_and(|st| st.brush.is_none())), "no loop is the whole path"); +} + +/// When Make cuts a stroke whose only setting that depends on direction is a gradient along or +/// across it, each edge runs the way its source path does, so the gradient keeps its direction. +#[test] +fn live_paint_make_keeps_the_direction_of_gradient_strokes() { + use vectorcraft_doc::StrokeGradientMode; + for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] { + for (x0, x1) in [(-20.0, 120.0), (120.0, -20.0)] { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let l = line(&mut s, (x0, 50.0), (x1, 50.0)); + let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); + set_rows(&mut s, l, vec![stroke_row(gradient, 2.0, |st| st.gradient_mode = mode)]); + s.execute("select.set", &json!({"ids": [a.0, l.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let pieces: Vec<(f64, f64)> = vectorcraft_tools::builder::edges(&node(&s, g)) + .iter() + .filter_map(|e| { + e.appearance.stroke().filter(|st| st.gradient_mode == mode)?; + let sp = e.path_data()?.subpaths.first()?; + Some((sp.anchors.first()?.p.x, sp.anchors.last()?.p.x)) + }) + .collect(); + assert_eq!(pieces.len(), 3, "{mode:?}, from {x0}"); + assert!( + pieces.iter().all(|&(p, q)| (q - p).signum() == (x1 - x0).signum()), + "{mode:?}, from {x0}: each piece runs the way the line does: {pieces:?}" + ); + } + } +} + +/// Make gives each face every fill row of its source that draws, in order and with their +/// opacity, blending mode, overprint and effects, and the source's gradients keep their fit on +/// the source; Expand keeps the rows, and Undo and Redo bring the group back with them. +#[test] +fn live_paint_make_keeps_all_drawn_fill_rows_and_attributes() { + let mut s = session(); + let (a, b) = two(&mut s); + let lower = fill_row(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), |l| { + l.opacity = 0.4; + l.blend = BlendMode::Multiply; + l.overprint = true; + l.effects.push(Effect { id: "distort.roughen".into(), params: Value::Null, visible: true }); + }); + // A: two fills that draw, with a hidden one between them and an unpainted one above them. + set_rows( + &mut s, + a, + vec![ + lower.clone(), + fill_row(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), |l| l.visible = false), + fill_row(Paint::None, |_| {}), + stroke_row(Paint::None, 1.0, |_| {}), + ], + ); + // The top fill (and the stroke) become a gradient fitted to A's bounds. + let (want_fill, _) = paint_with_gradients(&mut s, a); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let want = vec![lower, fill_row(want_fill, |_| {})]; + let rows_within = |s: &Session, x0: f64, x1: f64| parts_within(s, g, x0, x1).0.iter().map(|f| f.appearance.items.clone()).collect::>(); + assert_eq!(rows_within(&s, 0.0, 50.0), vec![want.clone()], "A's face takes both fills that draw, the gradient at its fit on A"); + let blue = vec![fill_row(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), |_| {})]; + assert_eq!(rows_within(&s, 50.0, 100.0), vec![blue], "B is in front"); + s.execute("livePaint.expand", &json!({})).unwrap(); + let expanded = node(&s, g); + assert!(!vectorcraft_tools::builder::is_live_paint(&expanded)); + let kept = + expanded.children().unwrap().iter().filter(|c| c.geometric_bounds().is_some_and(|b| b.x1 <= 50.0 + 1e-6)).any(|c| c.appearance.items == want); + assert!(kept, "Expand keeps both fills"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(rows_within(&s, 0.0, 50.0), vec![want.clone()]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(rows_within(&s, 0.0, 50.0), vec![want]); +} + +/// A face takes the fill rows of the front-most covering source whose fill draws: a source in +/// front whose fill is hidden or transparent, or whose opacity is 0, does not hide the paint of the +/// source behind it, and a face that no such source covers has no paint. +#[test] +fn live_paint_make_uses_frontmost_source_with_drawn_fill() { + for front in ["hidden", "clear", "transparent object"] { + let mut s = session(); + let (a, b) = two(&mut s); + s.edit("t", |d, _| { + let n = d.node_mut(b).unwrap(); + let mut app = (*n.appearance).clone(); + match front { + "hidden" => app.fill_mut().unwrap().visible = false, + "clear" => app.fill_mut().unwrap().opacity = 0.0, + _ => n.opacity = 0.0, + } + n.appearance = app.into(); + Ok(()) + }) + .unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let rows_within = |x0: f64, x1: f64| parts_within(&s, g, x0, x1).0.iter().map(|f| f.appearance.items.clone()).collect::>(); + let red = vec![fill_row(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), |_| {})]; + assert_eq!(rows_within(50.0, 100.0), vec![red], "{front}: the overlap shows A"); + assert_eq!(rows_within(100.0, 150.0), vec![vec![fill_row(Paint::None, |_| {})]], "{front}: B alone paints nothing"); + assert_eq!(parts_within(&s, g, 100.0, 150.0).0[0].opacity, 1.0, "{front}"); + } +} + #[test] fn live_paint_fill_assigns_the_face_under_the_point() { let mut s = session(); @@ -2781,7 +3177,7 @@ fn live_paint_fill_same_paint_is_no_op() { let mut before = snapshot(&s); let click = s.execute("livePaint.fill", &json!({"group": g.0, "point": [75, 50], "color": "#00ff00"})).unwrap(); let face = vectorcraft_tools::builder::face_at(&node(&s, g), Point::new(75.0, 50.0)).unwrap().id; - assert_eq!(click, json!({"group": g.0, "face": face.0}), "the usual reply"); + assert_eq!(click, json!({"group": g.0, "face": face.0, "count": 1}), "the usual reply"); assert_unchanged(&mut s, &mut before, "a click on a face with the paint"); s.execute("livePaint.fill", &drag).unwrap(); assert_unchanged(&mut s, &mut before, "a drag over faces with the paint"); @@ -2850,6 +3246,42 @@ fn live_paint_stroke_edge_same_paint_and_width_is_no_op() { assert_eq!(history_state(&s).0, before.1.0 + 1); } +/// With `scope`, faces and edges that already have the paint (and the width) are skipped: when +/// every face or edge the call reaches has it, nothing changes, and when some do, only the others +/// are painted, in one undo step. `count` is every face or edge reached. +#[test] +fn live_paint_scoped_fill_and_stroke_skip_parts_with_the_paint() { + let mut s = session(); + let g = live(&mut s); + s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "points": [[25, 50], [125, 50]], "color": "#00ff00"})).unwrap(); + let edges = + s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100, 50], "scope": "samePaint", "color": "#ff0000", "width": 3})).unwrap(); + redo_waiting_and_saved(&mut s, g); + let mut before = snapshot(&s); + let same = json!({"group": g.0, "point": [75, 50], "scope": "samePaint", "color": "#00ff00"}); + assert_eq!(s.execute("livePaint.fill", &same).unwrap()["count"], 3); + assert_unchanged(&mut s, &mut before, "a samePaint fill where every face has the paint"); + let again = json!({"group": g.0, "point": [100, 50], "scope": "samePaint", "color": "#ff0000", "width": 3}); + assert_eq!(s.execute("livePaint.strokeEdge", &again).unwrap()["count"], edges["count"]); + assert_unchanged(&mut s, &mut before, "a samePaint stroke where every edge has the paint and the width"); + // One face of the three without the paint: only it is filled, in one undo step. + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let red = Paint::solid(Color::from_hex("#ff0000").unwrap()); + s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "color": "#ff0000"})).unwrap(); + let depth = history_state(&s).0; + let like = serde_json::to_value(&green).unwrap(); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "scope": "samePaint", "match": like, "color": "#00ff00"})).unwrap(); + assert_eq!(r["count"], 3); + assert_eq!(history_state(&s).0, depth + 1); + let fill_at = |s: &Session, x: f64| vectorcraft_tools::builder::face_at(&node(s, g), Point::new(x, 50.0)).unwrap().appearance.fill_paint(); + for x in [25.0, 75.0, 125.0] { + assert_eq!(fill_at(&s, x), green, "{x}"); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(fill_at(&s, 25.0), red); + assert_eq!((fill_at(&s, 75.0), fill_at(&s, 125.0)), (green.clone(), green)); +} + #[test] fn live_paint_gap_options_same_values_are_no_op() { let mut s = session(); @@ -3012,6 +3444,472 @@ fn live_paint_fill_skips_hidden_faces() { assert!(!node(&s, hidden).visible); } +/// A Live Paint group of 50 pt squares side by side, 50 pt tall, with their left sides at each of +/// `xs`. +fn live_squares(s: &mut Session, xs: &[f64]) -> NodeId { + let ids: Vec = xs.iter().map(|&x| rect(s, x, 0.0, 50.0, 50.0).0).collect(); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()) +} + +/// The ids of the faces of `g` that hold `p`, in the order given. +fn faces_at(s: &Session, g: NodeId, ps: &[(f64, f64)]) -> Vec { + ps.iter().map(|&(x, y)| vectorcraft_tools::builder::face_at(&node(s, g), Point::new(x, y)).unwrap().id).collect() +} + +/// The edges of `g` whose bounds lie within `r`. +fn edges_in(s: &Session, g: NodeId, r: Rect) -> Vec { + let r = r.inflate(1e-6, 1e-6); + vectorcraft_tools::builder::edges(&node(s, g)) + .iter() + .filter(|e| e.geometric_bounds().is_some_and(|bb| r.contains(Point::new(bb.x0, bb.y0)) && r.contains(Point::new(bb.x1, bb.y1)))) + .map(|e| e.id) + .collect() +} + +/// Give `ids` stroke paint `paint` at weight 1 (none: no stroke), and `fill` to the faces in `faces`. +fn paint_parts(s: &mut Session, ids: &[NodeId], paint: &Paint, faces: &[(NodeId, Paint)]) { + s.edit("t", |d, _| { + for id in ids { + let n = d.node_mut(*id).unwrap(); + n.appearance.set_stroke(paint.clone()); + if let Some(st) = n.appearance.stroke_mut() { + st.width = 1.0; + } + } + for (id, fill) in faces { + d.node_mut(*id).unwrap().appearance.set_fill(fill.clone()); + } + Ok(()) + }) + .unwrap(); +} + +fn fill_of(s: &Session, id: NodeId) -> Paint { + node(s, id).appearance.fill_paint() +} + +/// A double-click on a face fills it and the faces reached across edges that draw no stroke; a +/// stroked edge and a hidden face stop the flood. +#[test] +fn live_paint_bucket_double_click_floods_across_unstroked_edges() { + let mut s = session(); + let g = live_squares(&mut s, &[0.0, 50.0, 100.0, 150.0]); + let [a, b, c, d] = faces_at(&s, g, &[(25.0, 25.0), (75.0, 25.0), (125.0, 25.0), (175.0, 25.0)])[..] else { panic!("four faces") }; + let all = edges_in(&s, g, Rect::new(-1.0, -1.0, 201.0, 51.0)); + paint_parts(&mut s, &all, &Paint::None, &[]); + let stroked = edges_in(&s, g, Rect::new(150.0, 0.0, 150.0, 50.0)); + assert!(!stroked.is_empty()); + paint_parts(&mut s, &stroked, &Paint::solid(Color::rgb(0.0, 0.0, 0.0)), &[]); + let white = fill_of(&s, d); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + assert_ne!(white, green); + let depth = s.doc().unwrap().history.undo.len(); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 25], "color": "#00ff00", "scope": "connected"})).unwrap(); + assert_eq!((&r["face"], &r["count"]), (&json!(a.0), &json!(3))); + assert_eq!([a, b, c, d].map(|f| fill_of(&s, f)), [green.clone(), green.clone(), green.clone(), white.clone()]); + assert_eq!(s.doc().unwrap().history.undo.len(), depth + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!([a, b, c].iter().all(|f| fill_of(&s, *f) != green)); + // A hidden face is not filled and does not pass the flood on. + s.execute("object.setProps", &json!({"ids": [b.0], "visible": false})).unwrap(); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 25], "color": "#00ff00", "scope": "connected"})).unwrap(); + assert_eq!(r["count"], 1); + assert_eq!([a, b, c].map(|f| fill_of(&s, f) == green), [true, false, false]); + while s.doc().unwrap().history.undo.len() > depth { + s.execute("edit.undo", &json!({})).unwrap(); + } + // The tool: two clicks and the double-click that follows them. + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.paint.fill = green.clone(); + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] { + s.pointer(&PointerEvent::new(kind, 125.0, 25.0), v).unwrap(); + } + assert_eq!([a, b, c, d].map(|f| fill_of(&s, f)), [green.clone(), green.clone(), green, white]); +} + +/// An edge that draws no stroke (fully transparent, or with its stroke hidden) lets a flood across +/// although it stores a stroke paint, and an edge scope compares the stroke paint each edge draws +/// with. +#[test] +fn live_paint_bucket_scopes_follow_the_stroke_an_edge_draws() { + let mut s = session(); + let g = live_squares(&mut s, &[0.0, 50.0, 100.0]); + let black = Paint::solid(Color::rgb(0.0, 0.0, 0.0)); + let all = edges_in(&s, g, Rect::new(-1.0, -1.0, 151.0, 51.0)); + paint_parts(&mut s, &all, &black, &[]); + let clear = edges_in(&s, g, Rect::new(50.0, 0.0, 50.0, 50.0)); + let hidden = edges_in(&s, g, Rect::new(100.0, 0.0, 100.0, 50.0)); + assert!(!clear.is_empty() && !hidden.is_empty()); + s.edit("t", |d, _| { + for id in &clear { + d.node_mut(*id).unwrap().opacity = 0.0; + } + for id in &hidden { + d.node_mut(*id).unwrap().appearance.stroke_mut().unwrap().visible = false; + } + Ok(()) + }) + .unwrap(); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 25], "color": "#00ff00", "scope": "connected"})).unwrap(); + assert_eq!(r["count"], 3, "the flood crosses both middle edges"); + s.execute("edit.undo", &json!({})).unwrap(); + let stroke = |s: &mut Session, at: [f64; 2]| { + let r = s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": at, "color": "#00ff00", "width": 3, "scope": "samePaint"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + r["count"].clone() + }; + assert_eq!(stroke(&mut s, [25.0, 0.0]), json!(all.len() - clear.len() - hidden.len()), "the edges that draw black"); + assert_eq!(stroke(&mut s, [50.0, 25.0]), json!(clear.len() + hidden.len()), "the edges that draw no stroke"); + // With the top edge between them fully transparent too, the connected scope walks from the edge + // at x = 50 through it to the edge at x = 100, and reaches no edge that draws black. + let top = edges_in(&s, g, Rect::new(50.0, 0.0, 100.0, 0.0)); + assert_eq!(top.len(), 1); + s.edit("t", |d, _| { + d.node_mut(top[0]).unwrap().opacity = 0.0; + Ok(()) + }) + .unwrap(); + let r = + s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [50, 25], "color": "#00ff00", "width": 3, "scope": "connected"})).unwrap(); + assert_eq!(r["count"], json!(clear.len() + top.len() + hidden.len())); +} + +/// `livePaint.strokeEdge` paints an edge's topmost stroke that is shown, so the edge then draws +/// with the paint; an edge whose strokes are all hidden takes a new stroke above them. Painting +/// again changes nothing. +#[test] +fn live_paint_stroke_edge_paints_the_shown_stroke() { + let mut s = session(); + let g = live_squares(&mut s, &[0.0, 50.0]); + let black = Paint::solid(Color::rgb(0.0, 0.0, 0.0)); + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let all = edges_in(&s, g, Rect::new(-1.0, -1.0, 101.0, 51.0)); + paint_parts(&mut s, &all, &black, &[]); + let middle = edges_in(&s, g, Rect::new(50.0, 0.0, 50.0, 50.0)); + let left: Vec = edges_in(&s, g, Rect::new(0.0, 0.0, 50.0, 50.0)).into_iter().filter(|e| !middle.contains(e)).collect(); + let (&[m], &[l]) = (&middle[..], &left[..]) else { panic!("one middle edge and one other edge round the left face") }; + // The middle edge draws red under a hidden black stroke; every stroke of the left face's other + // edge is hidden. + s.edit("t", |d, _| { + let n = d.node_mut(m).unwrap(); + n.appearance.items.insert(0, AppearanceItem::Stroke(vectorcraft_doc::appearance::StrokeLayer::new(red.clone(), 1.0))); + n.appearance.stroke_mut().unwrap().visible = false; + d.node_mut(l).unwrap().appearance.stroke_mut().unwrap().visible = false; + Ok(()) + }) + .unwrap(); + let drawn = |s: &Session, id: NodeId| vectorcraft_tools::builder::lp_stroke_paint(&node(s, id)); + assert_eq!(drawn(&s, m), red); + let paint = |s: &mut Session, at: [f64; 2]| { + s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": at, "color": "#00ff00", "width": 2})).unwrap(); + }; + for (id, at) in [(m, [50.0, 25.0]), (l, [0.0, 25.0])] { + paint(&mut s, at); + assert_eq!(drawn(&s, id), green, "{id}"); + let depth = history_state(&s).0; + paint(&mut s, at); + assert_eq!(history_state(&s).0, depth, "painting {id} again"); + } + assert_eq!(node(&s, m).appearance.items.len(), 2, "the hidden stroke stays as it was"); + assert_eq!(node(&s, l).appearance.items.len(), 2, "a new stroke above the hidden one"); +} + +/// With Cmd held, the Live Paint Bucket's presses go to the selection tool Cmd lends, and the +/// double-click and triple-click that follow them paint nothing. +#[test] +fn live_paint_bucket_cmd_double_and_triple_click_paint_nothing() { + let mut s = session(); + let g = live_squares(&mut s, &[0.0, 50.0, 100.0]); + let faces = faces_at(&s, g, &[(25.0, 25.0), (75.0, 25.0), (125.0, 25.0)]); + let all = edges_in(&s, g, Rect::new(-1.0, -1.0, 151.0, 51.0)); + paint_parts(&mut s, &all, &Paint::None, &[]); + let before: Vec = faces.iter().map(|f| fill_of(&s, *f)).collect(); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.paint.fill = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let cmd = Mods { cmd: true, ..Default::default() }; + let (down, up) = (PointerKind::Down, PointerKind::Up); + for kind in [down, up, down, up, PointerKind::DoubleClick, down, up, PointerKind::TripleClick] { + s.pointer(&PointerEvent::new(kind, 75.0, 25.0).with_mods(cmd), v).unwrap(); + } + assert_eq!(faces.iter().map(|f| fill_of(&s, *f)).collect::>(), before); + assert_eq!(s.tool_id(), "livePaintBucket"); +} + +/// The flood crosses curved edges, and reaches a face 0.012 pt wide. +#[test] +fn live_paint_bucket_double_click_floods_curved_and_narrow_faces() { + let mut s = session(); + let ids: Vec = [0.0, 60.0] + .iter() + .map(|&x| s.execute("shape.ellipse", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap()) + .collect(); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let all = edges_in(&s, g, Rect::new(-1.0, -1.0, 161.0, 101.0)); + paint_parts(&mut s, &all, &Paint::None, &[]); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [20, 50], "color": "#00ff00", "scope": "connected"})).unwrap(); + assert_eq!(r["count"], 3, "both lunes and the lens between them"); + + let mut s = session(); + let ids = [rect(&mut s, 0.0, 0.0, 50.0, 50.0).0, rect(&mut s, 49.988, 0.0, 50.0, 50.0).0]; + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let [a, sliver, c] = faces_at(&s, g, &[(25.0, 25.0), (49.994, 25.0), (75.0, 25.0)])[..] else { panic!("three faces") }; + assert!(a != sliver && sliver != c); + let all = edges_in(&s, g, Rect::new(-1.0, -1.0, 101.0, 51.0)); + paint_parts(&mut s, &all, &Paint::None, &[]); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 25], "color": "#00ff00", "scope": "connected"})).unwrap(); + assert_eq!(r["count"], 3); + assert_eq!([a, sliver, c].map(|f| fill_of(&s, f)), [green.clone(), green.clone(), green]); +} + +/// The flood crosses a hidden edge, and an edge that a Gap Detection gap meets partway along, but +/// not the gap. +#[test] +fn live_paint_bucket_double_click_floods_across_hidden_edges_and_past_gaps() { + let mut s = session(); + let g = live_squares(&mut s, &[0.0, 50.0]); + let black = Paint::solid(Color::rgb(0.0, 0.0, 0.0)); + let all = edges_in(&s, g, Rect::new(-1.0, -1.0, 101.0, 51.0)); + paint_parts(&mut s, &all, &black, &[]); + let fill = |s: &mut Session, g: NodeId, at: [f64; 2]| { + s.execute("livePaint.fill", &json!({"group": g.0, "point": at, "color": "#00ff00", "scope": "connected"})).unwrap()["count"].clone() + }; + assert_eq!(fill(&mut s, g, [25.0, 25.0]), 1, "a stroked edge stops the flood"); + s.execute("edit.undo", &json!({})).unwrap(); + let middle = edges_in(&s, g, Rect::new(50.0, 0.0, 50.0, 50.0)); + s.execute("object.setProps", &json!({"ids": middle.iter().map(|e| e.0).collect::>(), "visible": false})).unwrap(); + assert_eq!(fill(&mut s, g, [25.0, 25.0]), 2, "a hidden edge does not"); + + // Square B (100..200 by 0..100) over square C (100..200 by 100..200), and a line down the middle + // of B that stops 2 pt short of their shared edge: Gap Detection closes it, so B is two faces + // that each run along part of the shared edge, which is one edge segment. + let mut s = session(); + let mut ids = vec![rect(&mut s, 100.0, 0.0, 100.0, 100.0).0, rect(&mut s, 100.0, 100.0, 100.0, 100.0).0]; + let line = s.execute("path.create", &json!({"anchors": [{"x": 150, "y": 0}, {"x": 150, "y": 98}]})).unwrap(); + ids.push(line["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("livePaint.gapOptions", &json!({"id": g.0, "detect": true, "size": "small"})).unwrap(); + let [b1, b2, c] = faces_at(&s, g, &[(125.0, 50.0), (175.0, 50.0), (150.0, 150.0)])[..] else { panic!("three faces") }; + assert!(b1 != b2 && b2 != c && b1 != c); + let all = edges_in(&s, g, Rect::new(99.0, -1.0, 201.0, 201.0)); + paint_parts(&mut s, &all, &black, &[]); + let shared = edges_in(&s, g, Rect::new(100.0, 100.0, 200.0, 100.0)); + assert!(!shared.is_empty()); + paint_parts(&mut s, &shared, &Paint::None, &[]); + assert_eq!(fill(&mut s, g, [150.0, 150.0]), 3, "from C across the shared edge to both faces of B"); + s.execute("edit.undo", &json!({})).unwrap(); + // With the line and the shared edge stroked, only the gap lies between the two faces of B. + paint_parts(&mut s, &shared, &black, &[]); + assert_eq!(fill(&mut s, g, [125.0, 50.0]), 1, "the flood does not pass the gap"); + + // B alone, its outline drawing no stroke and the line stroked: the two faces of B run along + // different parts of the outline's bottom segment, on the same side, and the flood does not + // join them there. + let mut s = session(); + let mut ids = vec![rect(&mut s, 100.0, 0.0, 100.0, 100.0).0]; + let line = s.execute("path.create", &json!({"anchors": [{"x": 150, "y": 0}, {"x": 150, "y": 98}]})).unwrap(); + ids.push(line["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("livePaint.gapOptions", &json!({"id": g.0, "detect": true, "size": "small"})).unwrap(); + assert_eq!(vectorcraft_tools::builder::faces(&node(&s, g)).len(), 2); + let all = edges_in(&s, g, Rect::new(99.0, -1.0, 201.0, 101.0)); + paint_parts(&mut s, &all, &Paint::None, &[]); + let line = edges_in(&s, g, Rect::new(150.0, 0.0, 150.0, 98.0)); + assert!(!line.is_empty() && line.len() < all.len()); + paint_parts(&mut s, &line, &black, &[]); + assert_eq!(fill(&mut s, g, [125.0, 50.0]), 1, "the gap parts the two faces along the bottom"); +} + +/// The flood crosses an edge into each face along it when a face's outline runs along the edge in +/// one segment and the edge is split where another line meets it. +#[test] +fn live_paint_bucket_double_click_floods_along_edges_split_at_a_junction() { + // A square with a line across its middle, and a line from the top at x = 30 that crosses it and + // stops 20 pt below. The bottom face's outline runs along the middle line in one segment, from + // x = 0 to 100; the middle line's edges are split at x = 30. + let mut s = session(); + let mut ids = vec![rect(&mut s, 0.0, 0.0, 100.0, 100.0).0]; + for anchors in [json!([{"x": 0, "y": 50}, {"x": 100, "y": 50}]), json!([{"x": 30, "y": 0}, {"x": 30, "y": 70}])] { + ids.push(s.execute("path.create", &json!({ "anchors": anchors })).unwrap()["id"].as_u64().unwrap()); + } + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let [left, right, bottom] = faces_at(&s, g, &[(15.0, 25.0), (65.0, 25.0), (65.0, 75.0)])[..] else { panic!("three faces") }; + assert!(left != right && right != bottom && left != bottom); + let all = edges_in(&s, g, Rect::new(-1.0, -1.0, 101.0, 101.0)); + paint_parts(&mut s, &all, &Paint::None, &[]); + let down = edges_in(&s, g, Rect::new(30.0, 0.0, 30.0, 70.0)); + assert!(!down.is_empty() && down.len() < all.len()); + paint_parts(&mut s, &down, &Paint::solid(Color::rgb(0.0, 0.0, 0.0)), &[]); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [65, 75], "color": "#00ff00", "scope": "connected"})).unwrap(); + assert_eq!(r["count"], 3, "from the bottom face to both faces above the line"); + assert_eq!([left, right, bottom].map(|f| fill_of(&s, f)), [green.clone(), green.clone(), green.clone()]); + s.execute("edit.undo", &json!({})).unwrap(); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [15, 25], "color": "#00ff00", "scope": "connected"})).unwrap(); + assert_eq!(r["count"], 3, "from the upper left face down to the bottom face and up again"); +} + +/// A triple-click on a face fills every face of the group with that face's fill, joined to it or +/// not. Through the tool, the fill compared is the face's fill before the first click. +#[test] +fn live_paint_bucket_triple_click_fills_every_face_of_the_same_fill() { + let mut s = session(); + let g = live_squares(&mut s, &[0.0, 50.0, 100.0, 150.0]); + let [a, b, c, d] = faces_at(&s, g, &[(25.0, 25.0), (75.0, 25.0), (125.0, 25.0), (175.0, 25.0)])[..] else { panic!("four faces") }; + let (red, blue) = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0))); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let all = edges_in(&s, g, Rect::new(-1.0, -1.0, 201.0, 51.0)); + paint_parts(&mut s, &all, &Paint::None, &[(a, red.clone()), (b, blue.clone()), (c, red.clone()), (d, red.clone())]); + let stroked = edges_in(&s, g, Rect::new(100.0, 0.0, 100.0, 50.0)); + paint_parts(&mut s, &stroked, &Paint::solid(Color::rgb(0.0, 0.0, 0.0)), &[]); + let depth = s.doc().unwrap().history.undo.len(); + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 25], "color": "#00ff00", "scope": "samePaint"})).unwrap(); + assert_eq!(r["count"], 3); + assert_eq!([a, b, c, d].map(|f| fill_of(&s, f)), [green.clone(), blue.clone(), green.clone(), green.clone()]); + s.execute("edit.undo", &json!({})).unwrap(); + // The tool: the first click fills the face, the double-click floods across x = 50 into B, and + // the triple-click fills the faces that were red. + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.paint.fill = green.clone(); + for kind in [ + PointerKind::Down, + PointerKind::Up, + PointerKind::Down, + PointerKind::Up, + PointerKind::DoubleClick, + PointerKind::Down, + PointerKind::Up, + PointerKind::TripleClick, + ] { + s.pointer(&PointerEvent::new(kind, 25.0, 25.0), v).unwrap(); + } + assert_eq!([a, b, c, d].map(|f| fill_of(&s, f)), [green.clone(), green.clone(), green.clone(), green.clone()]); + // A match given by the caller is the fill compared. + while s.doc().unwrap().history.undo.len() > depth { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!([a, b, c, d].map(|f| fill_of(&s, f)), [red.clone(), blue.clone(), red.clone(), red.clone()]); + let r = s + .execute( + "livePaint.fill", + &json!({"group": g.0, "point": [25, 25], "color": "#00ff00", "scope": "samePaint", "match": serde_json::to_value(&blue).unwrap()}), + ) + .unwrap(); + assert_eq!(r["count"], 2); + assert_eq!([a, b, c, d].map(|f| fill_of(&s, f)), [green.clone(), green, red.clone(), red]); +} + +/// A triple-click whose first click makes the Live Paint group from the selected paths matches the +/// fill the face was made with. +#[test] +fn live_paint_bucket_triple_click_after_making_the_group_matches_the_made_fill() { + let mut s = session(); + let ids: Vec = [0.0, 100.0, 200.0].iter().map(|&x| rect(&mut s, x, 0.0, 50.0, 50.0)).collect(); + let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + for (id, c) in ids.iter().zip([red, red, blue]) { + set_fill(&mut s, *id, c); + } + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.paint.fill = green.clone(); + for kind in [ + PointerKind::Down, + PointerKind::Up, + PointerKind::Down, + PointerKind::Up, + PointerKind::DoubleClick, + PointerKind::Down, + PointerKind::Up, + PointerKind::TripleClick, + ] { + s.pointer(&PointerEvent::new(kind, 25.0, 25.0), v).unwrap(); + } + let g = vectorcraft_tools::builder::live_paint_at(&s.doc().unwrap().doc, Point::new(25.0, 25.0)).expect("the clicks made a group"); + let faces = faces_at(&s, g, &[(25.0, 25.0), (125.0, 25.0), (225.0, 25.0)]); + assert_eq!(faces.iter().map(|f| fill_of(&s, *f)).collect::>(), [green.clone(), green, Paint::solid(blue)]); +} + +/// A double-click on an edge paints the edges joined to it that have its stroke, and a +/// triple-click every edge of the group with that stroke; an edge with another stroke is left. +#[test] +fn live_paint_bucket_double_and_triple_click_paint_joined_and_same_stroke_edges() { + let mut s = session(); + let g = live_squares(&mut s, &[0.0, 50.0, 200.0]); + let black = Paint::solid(Color::rgb(0.0, 0.0, 0.0)); + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let joined = edges_in(&s, g, Rect::new(0.0, 0.0, 100.0, 50.0)); + let apart = edges_in(&s, g, Rect::new(200.0, 0.0, 250.0, 50.0)); + let middle = edges_in(&s, g, Rect::new(50.0, 0.0, 50.0, 50.0)); + assert!(!apart.is_empty() && !middle.is_empty()); + paint_parts(&mut s, &joined, &black, &[]); + paint_parts(&mut s, &apart, &black, &[]); + paint_parts(&mut s, &middle, &red, &[]); + let stroke = |s: &Session, ids: &[NodeId]| ids.iter().map(|id| node(s, *id).appearance.stroke_paint()).collect::>(); + let outer: Vec = joined.iter().copied().filter(|e| !middle.contains(e)).collect(); + let r = + s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [25, 0], "color": "#00ff00", "width": 3, "scope": "connected"})).unwrap(); + assert_eq!(r["count"], outer.len()); + assert!(stroke(&s, &outer).iter().all(|p| *p == green)); + assert!(outer.iter().all(|e| node(&s, *e).appearance.stroke_width() == 3.0)); + assert!(stroke(&s, &middle).iter().all(|p| *p == red), "another stroke is left"); + assert!(stroke(&s, &apart).iter().all(|p| *p == black), "edges not joined are left"); + s.execute("edit.undo", &json!({})).unwrap(); + let r = s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [25, 0], "color": "#00ff00", "scope": "samePaint"})).unwrap(); + assert_eq!(r["count"], outer.len() + apart.len()); + assert!(stroke(&s, &outer).iter().chain(&stroke(&s, &apart)).all(|p| *p == green)); + assert!(stroke(&s, &middle).iter().all(|p| *p == red)); + // A hidden edge is not painted. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.setProps", &json!({"ids": [apart[0].0], "visible": false})).unwrap(); + let r = s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [25, 0], "color": "#00ff00", "scope": "samePaint"})).unwrap(); + assert_eq!(r["count"], outer.len() + apart.len() - 1); + assert_eq!(node(&s, apart[0]).appearance.stroke_paint(), black); +} + +/// A `scope` that is not one of the three, a `match` that is not a paint or is given with a scope +/// that compares none, and a scope with a drag's points are refused; nothing changes. +#[test] +fn live_paint_scope_and_match_are_checked() { + let mut s = session(); + let g = live(&mut s); + let none = serde_json::to_value(Paint::None).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let depth = s.doc().unwrap().history.undo.len(); + for (cmd, p) in [ + ("livePaint.fill", json!({"group": g.0, "point": [75, 50], "scope": "all"})), + ("livePaint.fill", json!({"group": g.0, "point": [75, 50], "scope": 2})), + ("livePaint.fill", json!({"group": g.0, "point": [75, 50], "match": none})), + ("livePaint.fill", json!({"group": g.0, "point": [75, 50], "scope": "connected", "match": none})), + ("livePaint.fill", json!({"group": g.0, "point": [75, 50], "scope": "samePaint", "match": "red"})), + ("livePaint.fill", json!({"group": g.0, "point": [75, 50], "scope": "connected", "points": [[75, 50], [125, 50]]})), + ("livePaint.strokeEdge", json!({"group": g.0, "point": [100, 50], "scope": "Connected"})), + ("livePaint.strokeEdge", json!({"group": g.0, "point": [100, 50], "scope": "one", "match": none})), + ("livePaint.strokeEdge", json!({"group": g.0, "point": [100, 50], "scope": "samePaint", "match": {"type": "plaid"}})), + ] { + assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}"); + } + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), depth); + // "one" and null are a click's scope. + for scope in [json!("one"), Value::Null] { + let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [75, 50], "scope": scope})).unwrap(); + assert_eq!(r["count"], 1); + } +} + #[test] fn live_paint_release_and_expand() { let mut s = session(); diff --git a/crates/engine/src/tests/gradient.rs b/crates/engine/src/tests/gradient.rs index b8c8bcd325..9f404b050d 100644 --- a/crates/engine/src/tests/gradient.rs +++ b/crates/engine/src/tests/gradient.rs @@ -219,9 +219,10 @@ fn rotate_reflect_and_scale_carry_unplaced_gradients() { s.execute("object.rotate", &json!({"angle": 90})).unwrap(); let g = fill_geom(&s, id); assert!((g.end.x - g.start.x).abs() < 1e-9 && (g.length() - 100.0).abs() < 1e-9, "vertical after a 90° turn: {g:?}"); - s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap(); + s.execute("object.reflect", &json!({"axis": "horizontal", "basis": "page"})).unwrap(); let r = fill_geom(&s, id); - // The vertical vector is centred on the object, so the reflection swaps its ends. + // The vertical vector is centred on the object, so the reflection across the page's + // horizontal axis swaps its ends. assert!(r.start.distance(g.end) < 1e-9 && r.end.distance(g.start) < 1e-9, "{g:?} → {r:?}"); // A circle's radial gradient becomes an ellipse under a non-uniform scale. diff --git a/crates/engine/src/tests/journal.rs b/crates/engine/src/tests/journal.rs index 700c17883d..93c2390237 100644 --- a/crates/engine/src/tests/journal.rs +++ b/crates/engine/src/tests/journal.rs @@ -406,7 +406,7 @@ fn a_batch_step_records_what_it_took_from_the_preferences() { ]); s.execute("command.batch", &json!({ "commands": steps })).unwrap(); let p = &s.journal.last().unwrap().1; - assert_eq!(p["commands"][2]["params"], json!({"sx": 300, "strokes": true, "corners": false})); + assert_eq!(p["commands"][2]["params"], json!({"sx": 300, "strokes": true, "corners": false, "basis": "box"})); // Replayed with other preferences, the scale still scales the stroke. let r = replayed(&s); assert!(!r.prefs.scale_strokes); diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index a294cc9036..1572004d11 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1700,6 +1700,7 @@ fn filters_set_effects_and_stroke_styles() { ("test.engine.badn", json!([{"id": "plugin.test.engine.fx", "params": {"n": "two"}}]), "`n`"), ("test.engine.file", json!([{"id": "distort.glass", "params": {"textureImage": {"path": "/etc/hosts"}}}]), "textureImage"), ("test.engine.file2", json!([{"id": "texture.texturizer", "params": {"textureImage": {"path": "/etc/hosts"}}}]), "textureImage"), + ("test.engine.file3", json!([{"id": "artistic.roughPastels", "params": {"textureImage": {"path": "/etc/hosts"}}}]), "textureImage"), ("test.engine.two3d", json!([{"id": "threeD.extrude"}, {"id": "threeD.extrude"}]), "one 3D effect"), ] { let e = filter(&mut s, name, fx).unwrap_err().to_string(); diff --git a/crates/engine/src/tests/rasterfilters.rs b/crates/engine/src/tests/rasterfilters.rs index 521ba0adbf..d1075c1207 100644 --- a/crates/engine/src/tests/rasterfilters.rs +++ b/crates/engine/src/tests/rasterfilters.rs @@ -323,8 +323,7 @@ fn glass_loads_a_texture_saves_it_and_draws_it() { assert_eq!(doc.node(NodeId(g)).unwrap().appearance.effects[0].params, glass_params(&s, g)); assert_eq!(Renderer::new().render_region(&doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true).pixels, loaded.pixels); // The same file by path, through Effect Options, stores the same texture. - let dir = std::env::temp_dir().join(format!("vc-glass-texture-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let dir = vectorcraft_testkit::TempDir::new("glass-texture"); let path = dir.join("rings.png"); std::fs::write(&path, &png).unwrap(); let (mut t, h) = striped(); @@ -334,7 +333,7 @@ fn glass_loads_a_texture_saves_it_and_draws_it() { assert_eq!(glass_params(&t, h)["textureImage"], json!(stored)); assert_eq!(glass_params(&t, h)["texture"], json!("loaded")); assert_eq!(render(&t).pixels, loaded.pixels); - let _ = std::fs::remove_dir_all(&dir); + drop(dir); // The stored string is taken as it is (as effect.list gives it, and a graphic style copies it). t.execute("effect.apply", &json!({"effect": "distort.glass", "ids": [h], "params": {"textureImage": stored, "texture": "frosted"}})).unwrap(); let second = t.doc().unwrap().doc.node(NodeId(h)).unwrap().appearance.effects[1].params.clone(); @@ -391,6 +390,53 @@ fn texturizer_loads_a_texture_and_draws_it() { assert_eq!(Renderer::new().render_region(&doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true).pixels, loaded.pixels); } +/// Effect › Artistic › Rough Pastels › Load Texture: an image file, given as bytes or by path, is +/// stored with the effect and picked; it draws as the surface, a save and open keeps it, a file +/// that isn't an image is refused without a change, and undo takes it away. +#[test] +fn rough_pastels_loads_a_texture_saves_it_and_draws_it() { + let png = rings_png(); + let data = vectorcraft_format::base64_encode(&png); + let (mut s, g) = striped(); + let file = json!({"name": "rings.png", "dataBase64": data}); + s.execute("effect.apply", &json!({"effect": "artistic.roughPastels", "ids": [g], "params": {"relief": 20, "textureImage": file}})).unwrap(); + let p = glass_params(&s, g); + assert_eq!(p["texture"], json!("loaded"), "loading a texture picks it"); + let stored = p["textureImage"].as_str().unwrap().to_string(); + assert_eq!(vectorcraft_render::effects::pixel::texture(&stored).unwrap().size(), (12, 10)); + let loaded = render(&s); + for (_, builtin) in vectorcraft_render::effects::pixel::TEXTURES { + s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"texture": builtin}})).unwrap(); + assert!(mean_diff(&loaded, &render(&s)) > 0.001, "{builtin}"); + } + s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"texture": "loaded"}})).unwrap(); + assert_eq!(render(&s).pixels, loaded.pixels, "the texture stayed with the effect"); + let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(doc.node(NodeId(g)).unwrap().appearance.effects[0].params, glass_params(&s, g)); + assert_eq!(Renderer::new().render_region(&doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true).pixels, loaded.pixels); + // A file that isn't an image changes nothing. + let junk = vectorcraft_format::base64_encode(b"not an image"); + let before = glass_params(&s, g); + let e = s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"textureImage": {"dataBase64": junk}}})).unwrap_err(); + assert!(e.to_string().contains("textureImage"), "{e}"); + let e = s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"textureImage": 5}})).unwrap_err(); + assert!(e.to_string().contains("textureImage"), "{e}"); + assert_eq!(glass_params(&s, g), before); + // The same file by path, through Effect Options, stores the same texture; undo takes it away. + let dir = vectorcraft_testkit::TempDir::new("rough-pastels-texture"); + let path = dir.join("rings.png"); + std::fs::write(&path, &png).unwrap(); + let (mut t, h) = striped(); + t.execute("effect.apply", &json!({"effect": "artistic.roughPastels", "ids": [h], "params": {"relief": 20}})).unwrap(); + assert!(glass_params(&t, h).get("textureImage").is_none()); + t.execute("effect.setParams", &json!({"index": 0, "ids": [h], "params": {"textureImage": {"path": path.to_string_lossy()}}})).unwrap(); + drop(dir); + assert_eq!((&glass_params(&t, h)["textureImage"], &glass_params(&t, h)["texture"]), (&json!(stored), &json!("loaded"))); + assert_eq!(render(&t).pixels, loaded.pixels); + t.execute("edit.undo", &json!({})).unwrap(); + assert!(glass_params(&t, h).get("textureImage").is_none(), "undone"); +} + #[test] fn spin_blur_reaches_past_the_corners() { let (mut s, g) = striped(); diff --git a/crates/engine/src/tests/scalestrokes.rs b/crates/engine/src/tests/scalestrokes.rs index d03a9ebf8c..4d8349b6a4 100644 --- a/crates/engine/src/tests/scalestrokes.rs +++ b/crates/engine/src/tests/scalestrokes.rs @@ -216,7 +216,7 @@ fn the_journal_records_the_scale_options_used() { run(&mut s, "object.scale", json!({"sx": 150})); let (cmd, p) = s.journal.last().unwrap().clone(); assert_eq!(cmd, "object.scale"); - assert_eq!(p, json!({"sx": 150, "strokes": false, "corners": true})); + assert_eq!(p, json!({"sx": 150, "strokes": false, "corners": true, "basis": "box"})); // An explicit value stays; moves don't scale, so they record nothing. run(&mut s, "object.scale", json!({"sx": 150, "strokes": true})); assert_eq!(s.journal.last().unwrap().1["strokes"], json!(true)); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index d33ad587fe..f6bc163e65 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -651,7 +651,7 @@ impl Session { match ev.kind { PointerKind::Down | PointerKind::Drag => self.pointer_held = true, PointerKind::Up | PointerKind::Move => self.pointer_held = false, - PointerKind::DoubleClick => {} + PointerKind::DoubleClick | PointerKind::TripleClick => {} } match ev.kind { PointerKind::Down => { diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index c2235eca77..0ade04c72a 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1854,6 +1854,80 @@ proptest! { } } +/// A 4 × 2 pixel RGB PSD file whose clipping path resource (2999) names its saved path "P" +/// (resource 2000): a closed subpath of a linked and two unlinked knots, an open subpath, and the +/// initial fill record saying the fill starts with all pixels. +fn clipped_psd() -> Vec { + let record = |selector: u16, body: &[i32]| { + let mut r = selector.to_be_bytes().to_vec(); + r.extend(body.iter().flat_map(|v| v.to_be_bytes())); + r.resize(26, 0); + r + }; + let one = 1 << 24; + let mut path = record(6, &[]); + path.extend(record(8, &[1 << 16])); + path.extend(record(0, &[3 << 16])); + path.extend(record(1, &[0, 0, 0, one / 2, 0, one])); + path.extend(record(2, &[one, one, one, one, one, one])); + path.extend(record(2, &[one, 0, one, 0, one, 0])); + path.extend(record(3, &[2 << 16])); + path.extend(record(5, &[-one, -one, 0, 0, 2 * one, 2 * one])); + path.extend(record(4, &[one / 4, one / 4, one / 4, one / 4, one / 4, one / 4])); + let mut res = b"8BIM\x07\xd0\x01P".to_vec(); + res.extend((path.len() as u32).to_be_bytes()); + res.extend(path); + res.extend(b"8BIM\x0b\xb7\0\0"); + res.extend(8u32.to_be_bytes()); + res.extend([1, b'P', 0, 0, 0, 0, 0, 0]); + let mut f = b"8BPS\0\x01\0\0\0\0\0\0\0\x03\0\0\0\x02\0\0\0\x04\0\x08\0\x03".to_vec(); + f.extend([0; 4]); + f.extend((res.len() as u32).to_be_bytes()); + f.extend(res); + f.extend([0; 4]); + f.extend([0, 0]); + f.extend((0..24u8).map(|v| v * 10)); + f +} + +#[test] +fn the_clipped_psd_sample_places_in_a_clip_group() { + let path = vectorcraft_engine::cmd::fileio::psdread::clipping_path(&clipped_psd()).unwrap(); + assert_eq!(path.subpaths.iter().map(|s| (s.anchors.len(), s.closed)).collect::>(), [(4, true), (3, true), (2, false)]); + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let r = s.execute("file.place", &json!({"name": "c.psd", "dataBase64": vectorcraft_format::base64_encode(&clipped_psd())})).unwrap(); + assert!(r["image"].is_u64(), "{r}"); +} + +proptest! { + #![proptest_config(config())] + + /// Mutated copies of a PSD file with a clipping path (its resources: the path records, their + /// selectors, counts and points, and the names) never crash reading the path, placing the file + /// or opening its merged image. + #[test] + fn mutated_psd_clipping_paths_never_panic(cut in 0usize..1000, edits in prop::collection::vec((0usize..300, any::()), 0..12)) { + let mut bytes = clipped_psd(); + let len = bytes.len(); + for (at, b) in edits { + if let Some(x) = bytes.get_mut(at % len) { + *x = b; + } + } + bytes.truncate((cut % (len + 1)).max(8)); + let r = catch_quiet(|| { + let _ = vectorcraft_engine::cmd::fileio::psdread::clipping_path(&bytes); + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let p = json!({"name": "c.psd", "dataBase64": vectorcraft_format::base64_encode(&bytes), "thumbnail": 8}); + let _ = no_panic(s.execute("file.place", &p)); + let _ = no_panic(s.execute("document.open", &json!({"name": "c.psd", "dataBase64": vectorcraft_format::base64_encode(&bytes), "layers": false}))); + }); + prop_assert!(r.is_ok(), "panicked: {:?}", r.err()); + } +} + /// 1-bit TIFFs compressed with CCITT Group 3: modified Huffman (Compression 2) in three strips, T.4 /// (Compression 3) with two-dimensional rows, fill bits, six EOLs at the end and each byte's lowest /// bit first, and T.4 two-dimensional rows in 16 × 16 tiles. diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 3df902b97f..609e0aedee 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -143,6 +143,10 @@ //! A path or compound path with a brush the reader reads live is written with an art style whose //! stroke is the brush's filter (`writer::brushes`). A brush is written only when the reader reads //! it back with its settings. +//! - Graphs, as the reader reads them (`read/graph.rs`): a graph on the layers whose records the +//! reader reads back as the same graph ([`crate::ai_graph`]) is written as its group between +//! `%_Gs`, its functional records and `%_GS`, each series' group tagged `Go` right after its end; +//! any other graph, and a graph with art left out, as its art, with a warning. //! //! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at //! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index a01f631638..c3e3e3c422 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -10907,3 +10907,73 @@ fn a_clipping_path_written_as_its_paint_keeps_one_text_wrap() { same_wraps(&wraps(read_back(&doc).layers[0].children().unwrap()), &[(None, 5.0, true)]); } } + +/// A graph whose records read back whole is written with them, each series' group tagged before +/// its name, and opens again as the same live graph, also from a PDF-compatible file's first page. +#[test] +fn graphs_are_written_with_their_records_and_open_live() { + use crate::ai_graph::sample; + for (what, doc) in sample::kept() { + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::GRAPHS), "{what}: {:?}", out.warnings); + let text = program(&doc); + assert_eq!((text.matches("%_Gs\n").count(), text.matches("%_GS\n").count()), (1, 1), "{what}: {text}"); + assert!(text.contains("U\n%_2 ") && text.contains(" 0 0 Go\n%_/ArtDictionary :\n"), "{what}: the tag comes before the series' name: {text}"); + let written = sample::graphs(&doc); + let graphs = sample::graphs(&read_back(&doc)); + assert_eq!(graphs.len(), 1, "{what}"); + assert!(sample::same(&written[0].0, &graphs[0].0), "{what}: {:#?}\n{:#?}", written[0].0, graphs[0].0); + assert_eq!(graphs[0].1, written[0].1, "{what}: the members and their series"); + } + let doc = sample::document(sample::spec(vectorcraft_doc::GraphKind::Column)); + let (p, _) = private_data(&doc, &opts()).unwrap(); + let pdf = + vectorcraft_pdf::export(&doc, &vectorcraft_pdf::PdfOptions { private_data: Some(p), ..vectorcraft_pdf::PdfOptions::uncompressed() }).unwrap(); + let (read, _) = crate::ai_alone(&vectorcraft_pdf::illustrator_data(&pdf, None).unwrap()).unwrap(); + let graphs = sample::graphs(&read); + assert_eq!(graphs.len(), 1); + assert!(sample::same(&sample::graphs(&doc)[0].0, &graphs[0].0)); +} + +/// A graph whose records wouldn't read back as it is is written as its art, without records, with +/// the warning. +#[test] +fn graphs_the_records_cant_keep_are_written_as_their_art() { + use crate::ai_graph::sample; + for (what, doc) in sample::unkept() { + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::GRAPHS), "{what}: {:?}", out.warnings); + let text = program(&doc); + assert!(!text.contains("%_Gs") && !text.contains(" Go\n") && !text.contains("%_GS"), "{what}: {text}"); + let read = read_back(&doc); + assert!(sample::graphs(&read).is_empty(), "{what}"); + let paths = |d: &Document| { + let mut count = 0; + d.walk(|n| count += usize::from(matches!(n.kind, NodeKind::Path { .. }))); + count + }; + assert_eq!(paths(&read), paths(&doc), "{what}: the graph's art is kept"); + } +} + +/// A graph with art left out for the object limit is written as the art there is room for, without +/// records or tags, with the warning; with room for all of it, it is written with its records. +#[test] +fn a_graph_with_art_left_out_is_written_without_records() { + use crate::ai_graph::sample; + let doc = sample::document(sample::spec(vectorcraft_doc::GraphKind::Column)); + let all = export_ai_editing(&doc, &opts()).unwrap(); + let read = crate::import::objects_read(&all.program).unwrap(); + let room = export_within(&doc, &opts(), read).unwrap(); + assert_eq!(room.program, all.program); + for max in [0, 1, read / 2, read - 1] { + let out = export_within(&doc, &opts(), max).unwrap(); + let text = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + assert!( + out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT) && out.warnings.iter().any(|w| w == writer::GRAPHS), + "{max}: {:?}", + out.warnings + ); + assert!(!text.contains("%_Gs") && !text.contains(" Go\n") && !text.contains("%_GS"), "{max}: {text}"); + } +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 98b027f79d..15bd97d3b5 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -295,6 +295,13 @@ pub(super) struct Writer<'a> { shown: Vec, /// The copy of an image that the last [`Self::image`] wrote as a window, when it did. windowed: Option, + /// Writing a graph's art with its records ([`Self::graph_records`]): a graph inside it is written + /// as its art. + in_graph: bool, + /// The `Go` line the next object, a graph's series group, is followed by. + series_tag: Option, + /// Where the `Go` lines of the graph being written start in [`Self::body`]. + graph_tags: Vec, } impl<'a> Writer<'a> { @@ -367,6 +374,9 @@ impl<'a> Writer<'a> { whole_images: HashMap::new(), shown: vec![], windowed: None, + in_graph: false, + series_tag: None, + graph_tags: vec![], } } @@ -642,6 +652,7 @@ impl<'a> Writer<'a> { /// One object: its state before it, its transparency, and its name and opacity mask after it. fn node(&mut self, n: &Node) { + let tag = self.series_tag.take(); if let NodeKind::Layer { .. } = n.kind { return self.write_layer(n, &[]); } @@ -652,7 +663,8 @@ impl<'a> Writer<'a> { if self.in_symbol { self.wrote_fixed |= symbols::redrawn(n); } - if n.extras().graph.is_some() && !self.in_look { + let graph = if n.extras().graph.is_some() && !self.in_look { self.graph_records(n) } else { None }; + if n.extras().graph.is_some() && !self.in_look && graph.is_none() { self.warn(GRAPHS); } // On the layers or in an opacity mask's art, outside art cut to a clipping object: with its @@ -680,14 +692,18 @@ impl<'a> Writer<'a> { let start = self.body.len(); // An image's copy written as the part that its opacity mask, among other regions, shows. let mut mask_window = None; + let charged = self.charges(); match &n.kind { NodeKind::Layer { .. } => {} NodeKind::Group { children, clip: true } => self.clip_group(children, xy), NodeKind::Group { children, clip: false } => { if self.open_group("u") { + self.in_graph |= graph.is_some(); for c in children { + self.series_tag = graph.as_ref().and_then(|g| g.tags.get(&c.id).cloned()); self.node(c); } + self.in_graph &= graph.is_none(); self.end_group("U", xy); } } @@ -790,6 +806,11 @@ impl<'a> Writer<'a> { // A group's dictionary goes between its end and its state block. let block = (self.block_end == self.body.len() && self.block_start >= start).then(|| self.body.split_off(self.block_start)); self.block_end = usize::MAX; + // A graph's series group is tagged right after its end, before its dictionary. + if let Some(t) = tag { + self.graph_tags.push(self.body.len()); + self.line(&t); + } let mask_written = self.dictionary(n); if let Some(b) = block { self.body.push_str(&b); @@ -803,6 +824,41 @@ impl<'a> Writer<'a> { if deform { self.line(&envelopes::plugin_line()); } + if let Some(g) = graph { + self.graph_end(g, start, charged); + } + } + + /// The records of graph `n` ([`crate::ai_graph::records`]) when it is written as a graph: on the + /// layers, outside art cut to a clipping object, symbols' art, live objects' art and another + /// graph, and without an art style anywhere in it. `None` when it is written as its art. + fn graph_records(&self, n: &Node) -> Option { + fn has_style(n: &Node, styled: &HashMap>) -> bool { + styled.contains_key(&n.id) || n.children().is_some_and(|c| c.iter().any(|c| has_style(c, styled))) + } + let placed = self.hidden == 0 && self.nest == 0 && self.drawn == 0 && self.clip.is_none() && !self.in_symbol && !self.in_graph; + if !placed || has_style(n, &self.styled) { + return None; + } + crate::ai_graph::records(n, self.to_art) + } + + /// The graph whose art was written from `start` on, charged from `charged`, ends: its records go + /// before the art and `%_GS` after it, or, when art in it was left out ([`Self::take_objects`]), + /// its `Go` lines are taken out and it stays its art, with a warning. + fn graph_end(&mut self, records: crate::ai_graph::Records, start: usize, charged: Charges) { + let tags = std::mem::take(&mut self.graph_tags); + if self.body.len() > start && !self.left_out_since(charged) { + self.body.insert_str(start, &records.head); + self.line(crate::ai_graph::END); + return; + } + for at in tags.into_iter().rev() { + if let Some(len) = self.body.get(at..).and_then(|rest| rest.find('\n')) { + self.body.replace_range(at..=at + len, ""); + } + } + self.warn(GRAPHS); } /// Live object `n`, of transparency `xy`, as a group of its art, with `warning` when it is given. diff --git a/crates/eps/src/ai_graph.rs b/crates/eps/src/ai_graph.rs new file mode 100644 index 0000000000..3e1cebd9f9 --- /dev/null +++ b/crates/eps/src/ai_graph.rs @@ -0,0 +1,661 @@ +//! A live graph's records, which the legacy `.ai` writer ([`crate::legacy_ai`]) and the editing data +//! writer ([`crate::ai_editing`]) both write around a graph's group (Adobe Illustrator File Format +//! Specification, version 7.0, §17 and appendix A): `%_Gs`, the functional records on `%_` lines +//! (`Gb`, `Gy`, `Gd`, three `Ga` … `GA` axes, `Gz`, `Gc` cells and `GC`), the drawn art in one group +//! with each series' group followed by `%_2 column 0 0 Go`, then `%_GS`. +//! +//! A graph is written so only when the `.ai` reader (`import::ai::read::graph`) reads every record +//! back as the same graph: one of the four graph types it reads (grouped and stacked column, line, +//! area), the settings it keeps, a table it holds, and drawn art it attaches the graph to (each +//! series' group a direct member of the graph's, of paths painted with one solid fill and one plain +//! line, and the plot's left and bottom edges drawn as stroked lines). Any other graph is written as +//! its art, with a warning. + +use std::collections::HashMap; +use std::fmt::Write; + +use kurbo::{PathSeg, Point}; +use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_doc::{ + Appearance, AppearanceItem, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, Node, NodeId, NodeKind, PiePosition, + PieSort, TickLength, ValueAxisSide, +}; +use vectorcraft_effects::stroke; +use vectorcraft_geom::{Affine, Rect}; + +use crate::ps; + +/// Most cells of a graph's table, its label row and column included. +const MAX_CELLS: usize = 2_600_000; + +/// Longest cell string, in bytes (appendix A, `Gc`). +const MAX_CELL_TEXT: usize = 255; + +/// Most characters of the text before or after a value axis' numbers (appendix A, `Ga`, `GA`). +const MAX_AFFIX: usize = 9; + +/// Most tick marks per division the reader keeps. +const MAX_TICK_MARKS: usize = 20; + +/// Most divisions of a value axis with set values the reader keeps. +const MAX_DIVISIONS: usize = 100; + +/// How far an axis line may be from the plot's edge, in points. +const EDGE: f64 = 0.01; + +/// Most values one `Gc` takes. +const CELLS_PER_RECORD: usize = 15; + +/// A graph's records: what goes before its group (`%_Gs` to `%_GC`), and the `Go` line that follows +/// each series' group, by the group's id. `%_GS` follows the group. +pub(crate) struct Records { + pub(crate) head: String, + pub(crate) tags: HashMap, +} + +/// The line that ends a graph's records, after its group. +pub(crate) const END: &str = "%_GS"; + +/// The records of graph `n` (a group holding [`vectorcraft_doc::NodeExtras::graph`]), its art written +/// through `to_art`; `None` when the reader would not read them back as the same graph. +pub(crate) fn records(n: &Node, to_art: Affine) -> Option { + let g = n.extras().graph.as_deref()?; + let NodeKind::Group { children, clip: false } = &n.kind else { return None }; + (n.visible && n.extras().mask.is_none() && all_shown(n)).then_some(())?; + let ty = kind_code(g.kind)?; + settings_kept(g).then_some(())?; + let (left_manual, left_min, left_max, left_step) = axis(g.ticks, g.axis_min, g.axis_max)?; + let (right_manual, right_min, right_max, right_step) = axis(g.right_ticks, g.right_axis_min, g.right_axis_max)?; + // The table as the graph draws it: `cells` leaves out rows and series past the model's limits. + let rows = g.rows.len(); + let width = g.rows.iter().map(Vec::len).max().unwrap_or(0); + ((1..=MAX_GRAPH_CATEGORIES).contains(&rows) && (1..=MAX_GRAPH_SERIES).contains(&width)).then_some(())?; + let cells = g.cells(); + cells.iter().flatten().all(|v| v.is_none_or(f64::is_finite)).then_some(())?; + cells.iter().flatten().any(Option::is_some).then_some(())?; + let first_row = usize::from(!g.series.is_empty()); + let first_col = usize::from(!g.categories.is_empty()); + (g.series.is_empty() || g.series.len() == width).then_some(())?; + (g.categories.is_empty() || g.categories.len() == rows).then_some(())?; + let (total_rows, total_cols) = (rows + first_row, width + first_col); + total_rows.checked_mul(total_cols).filter(|c| *c <= MAX_CELLS)?; + g.series.iter().chain(&g.categories).all(|l| l.len() <= MAX_CELL_TEXT && !l.chars().any(char::is_control)).then_some(())?; + [&g.prefix, &g.suffix, &g.right_prefix, &g.right_suffix] + .iter() + .all(|a| a.chars().count() <= MAX_AFFIX && !a.chars().any(char::is_control)) + .then_some(())?; + // The plot, and the art the reader attaches the graph to. + let r = g.rect; + (r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(())?; + let plot = to_art.transform_rect_bbox(r); + (plot.is_finite() && plot.width() > 0.0 && plot.height() > 0.0).then_some(())?; + let tags = series_groups(children, width, first_col, g.kind == GraphKind::Line)?; + draws_axes(n, r).then_some(())?; + let placed = n.geometric_bounds()?; + (r.x0 >= placed.x0 - EDGE && r.y0 >= placed.y0 - EDGE && r.x1 <= placed.x1 + EDGE && r.y1 <= placed.y1 + EDGE).then_some(())?; + + let mut head = String::from("%_Gs\n"); + let _ = writeln!(head, "%_{} {} {} {} Gb", real(plot.x0), real(plot.y1), real(plot.x1), real(plot.y0)); + let flag = |b: bool| u8::from(b); + let _ = writeln!( + head, + "%_{ty} {} {} 14 {} {} 0 {} 6 44 Gy", + flag(g.drop_shadow), + flag(g.first_row_in_front), + flag(g.mark_points), + flag(g.connect_points), + flag(g.legend_across_top) + ); + let _ = + writeln!(head, "%_10 2 {} 0 {} {} {} Gd", flag(g.first_column_in_front), real(g.column_width), real(g.cluster_width), flag(g.edge_to_edge)); + // Tick marks per division: 0 and 1 both mean one, at the labels, and the reader reads 0 as 1. + let (category_marks, left_marks, right_marks) = (g.category_tick_marks.max(1), g.tick_marks.max(1), g.right_tick_marks.max(1)); + let _ = writeln!(head, "%_1 () Ga\n%_0 {} 0 0 0 {category_marks} {} () GA", tick(g.category_tick_length), flag(g.ticks_between_labels)); + let _ = writeln!(head, "%_2 {} Ga", ps::bytes(g.prefix.as_bytes())); + let _ = writeln!( + head, + "%_{} {} {} {} {} {} 0 {} GA", + flag(left_manual), + tick(g.tick_length), + real(left_min), + real(left_max), + real(left_step), + left_marks, + ps::bytes(g.suffix.as_bytes()) + ); + let _ = writeln!(head, "%_4 {} Ga", ps::bytes(g.right_prefix.as_bytes())); + let _ = writeln!( + head, + "%_{} {} {} {} {} {} 0 {} GA", + flag(right_manual), + tick(g.right_tick_length), + real(right_min), + real(right_max), + real(right_step), + right_marks, + ps::bytes(g.right_suffix.as_bytes()) + ); + let _ = writeln!(head, "%_{total_rows} {total_cols} {first_row} {first_col} Gz"); + let mut table = Table::new(&mut head); + if first_row == 1 { + if first_col == 1 { + table.text(""); + } + for s in &g.series { + table.text(s); + } + } + for (i, row) in cells.iter().enumerate() { + if let Some(c) = g.categories.get(i) { + table.text(c); + } + for v in row { + match v { + Some(v) => table.num(*v), + None => table.text(""), + } + } + } + table.finish(); + head.push_str("%_GC\n"); + Some(Records { head, tags }) +} + +/// The `Gc` records of a table, its cells in rows: at most [`CELLS_PER_RECORD`] values each, one +/// string at most. +struct Table<'a> { + out: &'a mut String, + values: usize, + string: bool, +} + +impl<'a> Table<'a> { + fn new(out: &'a mut String) -> Self { + Self { out, values: 0, string: false } + } + + fn start(&mut self, string: bool) { + if self.values == CELLS_PER_RECORD || (string && self.string) { + self.finish(); + } + self.out.push_str(if self.values == 0 { "%_" } else { " " }); + self.values += 1; + self.string |= string; + } + + fn text(&mut self, t: &str) { + self.start(true); + self.out.push_str(&ps::bytes(t.as_bytes())); + } + + fn num(&mut self, v: f64) { + self.start(false); + self.out.push_str(&real(v)); + } + + fn finish(&mut self) { + if self.values > 0 { + self.out.push_str(" Gc\n"); + } + (self.values, self.string) = (0, false); + } +} + +/// `v` as a PostScript number that reads back as the same value. +fn real(v: f64) -> String { + if v == 0.0 { + "0".into() + } else if (1e-4..1e15).contains(&v.abs()) { + format!("{v}") + } else { + format!("{v:e}") + } +} + +/// `graphType`: 5 grouped column, 6 stacked column, 7 line, 10 area; the reader reads no other type. +fn kind_code(k: GraphKind) -> Option { + match k { + GraphKind::Column => Some(5), + GraphKind::StackedColumn => Some(6), + GraphKind::Line => Some(7), + GraphKind::Area => Some(10), + _ => None, + } +} + +/// `tickMarkType`: 13 none, 14 short, 15 the length of the plot. +fn tick(t: TickLength) -> u8 { + match t { + TickLength::None => 13, + TickLength::Short => 14, + TickLength::Full => 15, + } +} + +/// Whether `g`'s settings are ones the records give and the reader keeps: a legend beside or across +/// the top, every series of the graph's type, one value axis on the left, lines not filled, no graph +/// designs, label formatting or pie settings, column and cluster widths of 1% to 1000%, and at most +/// [`MAX_TICK_MARKS`] tick marks a division. +fn settings_kept(g: &GraphSpec) -> bool { + let percent = |p: f64| (1.0..=1000.0).contains(&p); + g.legend + && g.series_kinds.iter().all(|k| k.is_none_or(|k| k == g.kind)) + && g.value_axis == ValueAxisSide::Left + && !g.separate_scales + && g.right_series.is_empty() + && !g.filled_lines + && g.series_markers.iter().all(Option::is_none) + && g.series_columns.iter().all(Option::is_none) + && g.label_styles == LabelStyles::default() + && !g.pie_legend_in_wedges + && g.pie_position == PiePosition::Even + && g.pie_sort == PieSort::None + && percent(g.column_width) + && percent(g.cluster_width) + && [g.tick_marks, g.right_tick_marks, g.category_tick_marks].iter().all(|m| *m <= MAX_TICK_MARKS) +} + +/// A value axis' `useManualValues`, lowest and highest value and `betweenValue`: automatic (no tick +/// count and no values), or a whole number of divisions (1 to [`MAX_DIVISIONS`]) between a lowest +/// and a higher highest value, which the reader reads back as the same count. +fn axis(ticks: usize, min: Option, max: Option) -> Option<(bool, f64, f64, f64)> { + match (ticks, min, max) { + (0, None, None) => Some((false, 0.0, 0.0, 0.0)), + (1..=MAX_DIVISIONS, Some(lo), Some(hi)) if lo.is_finite() && hi.is_finite() && hi > lo => { + let step = (hi - lo) / ticks as f64; + let n = (hi - lo) / step; + let exact = step > 0.0 && step.is_finite() && (n - n.round()).abs() <= 1e-6 * n.round().max(1.0) && n.round() == ticks as f64; + exact.then_some((true, lo, hi, step)) + } + _ => None, + } +} + +/// Whether `n` and everything in it is shown. +fn all_shown(n: &Node) -> bool { + n.visible && n.children().is_none_or(|c| c.iter().all(|c| all_shown(c))) +} + +/// Whether `n` is shown, fully opaque, in Normal mode, not isolated and without an opacity mask or +/// effects: the reader keeps a series' paint only from groups and paths drawn so. +fn as_drawn(n: &Node) -> bool { + n.visible + && n.opacity == 1.0 + && n.blend == BlendMode::Normal + && !n.isolate + && n.extras().mask.is_none() + && !n.appearance.effects.iter().any(|e| e.visible) +} + +/// The visible fill and line `p` is written with as one path object: a solid fill, and a solid +/// plain line with its weight, each opaque, in Normal mode and without effects of its own. `None` +/// when it has other paint, more than one of either, or isn't one path of one subpath. +#[allow(clippy::type_complexity)] +fn plain_paint(p: &Node) -> Option<(Option, Option<(Paint, f64)>)> { + let NodeKind::Path { path, guide: false, clipping: false, .. } = &p.kind else { return None }; + (path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count() == 1).then_some(())?; + let (mut fill, mut line) = (None, None); + for item in &p.appearance.items { + let drawn = match item { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && s.width.is_finite(), + }; + if !drawn { + // The writers leave out what draws nothing; the reader reads nothing in its place. + continue; + } + let plain = + item.opacity() == 1.0 && item.blend() == BlendMode::Normal && item.effects().is_empty() && matches!(item.paint(), Paint::Solid { .. }); + plain.then_some(())?; + match item { + AppearanceItem::Fill(f) if fill.is_none() && line.is_none() => fill = Some(f.paint.clone()), + AppearanceItem::Stroke(s) if line.is_none() && stroke::is_plain(s) => line = Some((s.paint.clone(), s.width)), + _ => return None, + } + } + Some((fill, line)) +} + +/// The `Go` line of each series' group among `children`: a group whose series index is one of the +/// `series` the table has, each once, of paths [`as_drawn`] with one fill and one line alike +/// ([`plain_paint`]), every path filled unless the graph draws lines (`line`). Its column is the +/// table's: after the label column when there is one (`first_col`). `None` when a series has no +/// such group. +fn series_groups(children: &[std::sync::Arc], series: usize, first_col: usize, line: bool) -> Option> { + let mut tags = HashMap::new(); + let mut seen = vec![false; series]; + for c in children { + let Some(s) = c.extras().series_index else { continue }; + let s = usize::try_from(s).ok().filter(|s| *s < series)?; + let slot = seen.get_mut(s)?; + (!*slot).then_some(())?; + *slot = true; + let NodeKind::Group { children: paths, clip: false } = &c.kind else { return None }; + (as_drawn(c) && *c.appearance == Appearance::default() && !paths.is_empty()).then_some(())?; + let mut paints = vec![]; + for p in paths { + as_drawn(p).then_some(())?; + paints.push(plain_paint(p)?); + } + let fill = paints.iter().find_map(|(f, _)| f.clone()); + paints.iter().all(|(f, _)| f.is_none() || *f == fill).then_some(())?; + (line || paints.iter().all(|(f, _)| f.is_some())).then_some(())?; + let stroke = paints.first().and_then(|(_, s)| s.clone()); + paints.iter().all(|(_, s)| *s == stroke).then_some(())?; + tags.insert(c.id, format!("%_2 {} 0 0 Go", s + first_col)); + } + seen.iter().all(|s| *s).then_some(tags) +} + +/// Whether `art` draws plot `plot`'s left edge and its bottom edge (the document's y runs down) as +/// straight lines, each a shown path of one subpath with a plain line ([`plain_paint`]) in a shown +/// group, as the reader finds the axes. +fn draws_axes(art: &Node, plot: Rect) -> bool { + let near = |a: Point, b: Point| (a - b).hypot() <= EDGE; + let on = |s: &PathSeg, a: Point, b: Point| match s { + PathSeg::Line(l) => (near(l.p0, a) && near(l.p1, b)) || (near(l.p0, b) && near(l.p1, a)), + _ => false, + }; + let (top_left, bottom_left, bottom_right) = (Point::new(plot.x0, plot.y0), Point::new(plot.x0, plot.y1), Point::new(plot.x1, plot.y1)); + let (mut left, mut bottom) = (false, false); + let mut stack = vec![art]; + while let Some(n) = stack.pop() { + if !n.visible || n.opacity <= 0.0 || (left && bottom) { + continue; + } + match &n.kind { + NodeKind::Group { children, clip: false } => stack.extend(children.iter().map(|c| &**c)), + NodeKind::Path { path, .. } if plain_paint(n).is_some_and(|(_, line)| line.is_some()) => { + for s in path.to_bezpath().segments() { + left |= on(&s, top_left, bottom_left); + bottom |= on(&s, bottom_left, bottom_right); + } + } + _ => {} + } + } + left && bottom +} + +/// Graphs built as the graph tools draw them, for the writers' tests. +#[cfg(test)] +pub(crate) mod sample { + use std::sync::Arc; + + use vectorcraft_color::{Color, Paint}; + use vectorcraft_doc::{Document, GraphKind, GraphSpec, Node, PieSort, TickLength, ValueAxisSide}; + use vectorcraft_geom::{PathData, Point, Rect, shapes}; + + use super::Appearance; + + /// A graph of kind `kind`: series East and West, categories Q1 to Q3, West blank in Q2, on the + /// plot 100 100 300 250. + pub(crate) fn spec(kind: GraphKind) -> GraphSpec { + let mut g = GraphSpec { + kind, + rect: Rect::new(100.0, 100.0, 300.0, 250.0), + series: vec!["East".into(), "West".into()], + categories: vec!["Q1".into(), "Q2".into(), "Q3".into()], + ..GraphSpec::default() + }; + g.set_cells(vec![vec![Some(5.0), Some(3.0)], vec![Some(7.0), None], vec![Some(4.0), Some(6.0)]]); + g + } + + fn path(doc: &mut Document, p: PathData, fill: Paint, stroke: Paint) -> Arc { + Arc::new(Node::path(doc.alloc_id(), p, Appearance::basic(fill, stroke, 1.0))) + } + + /// Graph `g` drawn as a group: a group of each series' marks (a line graph's series as an unfilled + /// line, the others' as filled columns), its axes on the plot's left and bottom edges, and a + /// legend of a swatch for each series; its series groups named after the series. + pub(crate) fn graph(doc: &mut Document, g: GraphSpec) -> Node { + let r = g.rect; + let cells = g.cells(); + let width = cells.first().map_or(0, Vec::len); + let mut members = vec![]; + let axes = vec![ + path(doc, shapes::line(Point::new(r.x0, r.y0), Point::new(r.x0, r.y1)), Paint::None, Paint::solid(Color::BLACK)), + path(doc, shapes::line(Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)), Paint::None, Paint::solid(Color::BLACK)), + ]; + members.push(Arc::new(Node::group(doc.alloc_id(), axes))); + for s in 0..width { + let gray = Paint::solid(Color::gray(0.2 + 0.3 * s as f32)); + let points: Vec = cells + .iter() + .enumerate() + .filter_map(|(c, row)| { + row.get(s).copied().flatten().map(|v| Point::new(r.x0 + 20.0 + 60.0 * c as f64 + 15.0 * s as f64, r.y1 - v * 10.0)) + }) + .collect(); + let marks: Vec> = if g.kind == GraphKind::Line { + let mut bp = kurbo::BezPath::new(); + for (i, p) in points.iter().enumerate() { + if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) } + } + vec![path(doc, PathData::from_bezpath(&bp), Paint::None, gray)] + } else { + points.iter().map(|p| path(doc, shapes::rectangle(Rect::new(p.x, p.y, p.x + 10.0, r.y1)), gray.clone(), Paint::None)).collect() + }; + let mut series = Node::group(doc.alloc_id(), marks); + series.name = Some(g.series.get(s).cloned().unwrap_or_else(|| format!("Series {}", s + 1))); + series.extras_mut().series_index = Some(s as u32); + members.push(Arc::new(series)); + } + let swatches: Vec> = (0..width) + .map(|s| { + let y = r.y0 + 12.0 * s as f64; + path( + doc, + shapes::rectangle(Rect::new(r.x1 + 10.0, y, r.x1 + 18.0, y + 8.0)), + Paint::solid(Color::gray(0.2 + 0.3 * s as f32)), + Paint::None, + ) + }) + .collect(); + members.push(Arc::new(Node::group(doc.alloc_id(), swatches))); + let mut n = Node::group(doc.alloc_id(), members); + n.extras_mut().graph = Some(Box::new(g)); + n + } + + /// A 400 × 300 document holding `g` drawn by [`graph`] on its layer. + pub(crate) fn document(g: GraphSpec) -> Document { + let mut doc = Document::new(400.0, 300.0); + let layer = doc.layers[0].id; + let n = graph(&mut doc, g); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + doc + } + + /// Graphs whose records read back whole: each type the records give, every setting they keep + /// changed, labels with parentheses, backslashes and non-ASCII characters, a label of 255 bytes, + /// and a table without labels. + pub(crate) fn kept() -> Vec<(&'static str, Document)> { + let mut cases = vec![ + ("column", document(spec(GraphKind::Column))), + ("stacked column", document(spec(GraphKind::StackedColumn))), + ("line", document(spec(GraphKind::Line))), + ("area", document(spec(GraphKind::Area))), + ]; + let mut g = spec(GraphKind::Column); + (g.drop_shadow, g.first_row_in_front, g.first_column_in_front, g.legend_across_top) = (true, true, true, true); + (g.mark_points, g.connect_points, g.edge_to_edge) = (false, false, true); + (g.column_width, g.cluster_width) = (120.0, 50.0); + (g.tick_length, g.tick_marks, g.ticks, g.axis_min, g.axis_max) = (TickLength::Full, 3, 4, Some(0.0), Some(20.0)); + (g.right_tick_length, g.right_tick_marks, g.right_ticks, g.right_axis_min, g.right_axis_max) = (TickLength::None, 2, 5, Some(1.0), Some(6.0)); + (g.category_tick_length, g.category_tick_marks, g.ticks_between_labels) = (TickLength::Short, 2, true); + (g.prefix, g.suffix, g.right_prefix, g.right_suffix) = ("($".into(), "\\)".into(), "é".into(), " u".into()); + cases.push(("settings", document(g))); + let mut g = spec(GraphKind::Column); + g.series = vec!["A (1)".into(), "B\\2".into()]; + g.categories = vec![String::new(), "naïve".into(), "a".repeat(255)]; + g.set_cells(vec![vec![Some(1.0 / 3.0), Some(1e-7)], vec![None, Some(2.5)], vec![Some(12.0), None]]); + cases.push(("labels", document(g))); + let mut g = spec(GraphKind::Column); + (g.series, g.categories) = (vec![], vec![]); + cases.push(("no labels", document(g))); + cases + } + + /// Graphs the records can't give as they are, which are written as their art: the five other + /// types, settings the reader doesn't keep, a table it can't hold, and art it can't attach the + /// graph to. + pub(crate) fn unkept() -> Vec<(&'static str, Document)> { + let with = |f: &dyn Fn(&mut GraphSpec)| { + let mut g = spec(GraphKind::Column); + f(&mut g); + document(g) + }; + let mut cases: Vec<(&'static str, Document)> = [GraphKind::Bar, GraphKind::StackedBar, GraphKind::Scatter, GraphKind::Pie, GraphKind::Radar] + .into_iter() + .map(|k| (k.id(), document(spec(k)))) + .collect(); + cases.extend([ + ("no legend", with(&|g| g.legend = false)), + ("filled lines", with(&|g| (g.kind, g.filled_lines) = (GraphKind::Line, true))), + ("a series of another type", with(&|g| g.series_kinds = vec![None, Some(GraphKind::Line)])), + ("a marker design", with(&|g| g.series_markers = vec![Some("Star".into())])), + ("a column design", with(&|g| g.series_columns = vec![Some(Default::default())])), + ("label formatting", with(&|g| g.label_styles.values = Some(Default::default()))), + ("a value axis on both sides", with(&|g| g.value_axis = ValueAxisSide::Both)), + ("a series on the right axis", with(&|g| g.right_series = vec![1])), + ("pie settings", with(&|g| g.pie_sort = PieSort::All)), + ("a manual axis without its values", with(&|g| g.ticks = 4)), + ("values without a tick count", with(&|g| (g.axis_min, g.axis_max) = (Some(0.0), Some(10.0)))), + ("101 divisions", with(&|g| (g.ticks, g.axis_min, g.axis_max) = (101, Some(0.0), Some(101.0)))), + ("too many tick marks", with(&|g| g.tick_marks = 21)), + ("a column width past 1000%", with(&|g| g.column_width = 1001.0)), + ("a long prefix", with(&|g| g.prefix = "0123456789".into())), + ("a line break in a label", with(&|g| g.series[0] = "East\nside".into())), + ("a label of 256 bytes", with(&|g| g.categories[0] = "a".repeat(256))), + ("a value that isn't finite", with(&|g| g.rows[0][0] = f64::NAN)), + ("only blank cells", with(&|g| g.set_cells(vec![vec![None, None]; 3]))), + ("fewer labels than series", with(&|g| g.series.truncate(1))), + ]); + let drawn = |what: &'static str, f: &dyn Fn(&mut Vec>)| { + let mut doc = Document::new(400.0, 300.0); + let mut n = graph(&mut doc, spec(GraphKind::Column)); + if let Some(c) = n.children_mut() { + f(c); + } + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + (what, doc) + }; + cases.extend([ + drawn("a series without its group", &|c| { + c.remove(2); + }), + drawn("no axes", &|c| { + c.remove(0); + }), + drawn("a hidden legend", &|c| { + if let Some(l) = c.last_mut() { + Arc::make_mut(l).visible = false; + } + }), + drawn("a series drawn half opaque", &|c| Arc::make_mut(&mut c[1]).opacity = 0.5), + drawn("a series mark with a gradient", &|c| { + let g = vectorcraft_color::Gradient::new( + vectorcraft_color::GradientKind::Linear, + vec![vectorcraft_color::GradientStop::new(0.0, Color::BLACK), vectorcraft_color::GradientStop::new(1.0, Color::WHITE)], + ); + let series = Arc::make_mut(&mut c[1]); + if let Some(marks) = series.children_mut() { + let mark = Arc::make_mut(&mut marks[0]); + mark.appearance = Appearance::basic(Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(g))), Paint::None, 0.0).into(); + } + }), + ]); + cases + } + + /// The graphs in `doc`, each with the series index of its members. + pub(crate) fn graphs(doc: &Document) -> Vec<(GraphSpec, Vec>)> { + let mut out = vec![]; + doc.walk(|n| { + if let Some(g) = n.extras().graph.as_deref() { + out.push((g.clone(), n.children().map(|c| c.iter().map(|c| c.extras().series_index).collect()).unwrap_or_default())); + } + }); + out + } + + /// Whether `read` is the graph `written` was: the same data and settings, the plot in the same + /// place; its series' paint is the art's and its bounds are the art's as read. + pub(crate) fn same(written: &GraphSpec, read: &GraphSpec) -> bool { + let near = |a: Rect, b: Rect| (a.x0 - b.x0).abs() + (a.y0 - b.y0).abs() + (a.x1 - b.x1).abs() + (a.y1 - b.y1).abs() < 1e-6; + let plain = |g: &GraphSpec| GraphSpec { + rect: Rect::ZERO, + rows: vec![], + blanks: vec![], + series_paints: vec![], + placed: None, + placed_art: None, + series_kinds: vec![], + ..g.clone() + }; + near(written.rect, read.rect) && written.cells() == read.cells() && plain(written) == plain(read) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Numbers are written so they read back the same, small and large ones included. + #[test] + fn numbers_read_back_as_written() { + for v in [0.0, 1.0, -2.5, 1.0 / 3.0, 1e-7, 123_456_789_012_345_678.0, f64::MAX, f64::MIN_POSITIVE] { + assert_eq!(real(v).parse::().ok(), Some(v), "{v}"); + } + } + + /// A graph of `categories` rows of `series` values, drawn by [`sample::graph`]. + fn sized(categories: usize, series: usize) -> Node { + let mut g = sample::spec(GraphKind::Column); + (g.series, g.categories) = (vec![], vec![]); + g.set_cells(vec![vec![Some(1.0); series]; categories]); + sample::graph(&mut vectorcraft_doc::Document::new(400.0, 300.0), g) + } + + /// The table the reader holds: up to 10,000 categories and 256 series; past them, the graph is + /// its art. + #[test] + fn tables_up_to_the_readers_limits_are_written() { + assert!(records(&sized(MAX_GRAPH_CATEGORIES, 1), Affine::IDENTITY).is_some_and(|r| r.head.contains("%_10000 1 0 0 Gz\n"))); + assert!(records(&sized(MAX_GRAPH_CATEGORIES + 1, 1), Affine::IDENTITY).is_none()); + assert!(records(&sized(1, MAX_GRAPH_SERIES), Affine::IDENTITY).is_some_and(|r| r.tags.len() == MAX_GRAPH_SERIES)); + assert!(records(&sized(1, MAX_GRAPH_SERIES + 1), Affine::IDENTITY).is_none()); + } + + /// Tick-mark counts of 0, which mean one tick mark as 1 does, are written as 1, the count the + /// reader reads back. + #[test] + fn tick_mark_counts_of_0_are_written_as_1() { + let mut g = sample::spec(GraphKind::Column); + (g.tick_marks, g.right_tick_marks, g.category_tick_marks) = (0, 0, 0); + let n = sample::graph(&mut vectorcraft_doc::Document::new(400.0, 300.0), g); + let head = records(&n, Affine::IDENTITY).map(|r| r.head).unwrap_or_default(); + let marks: Vec<&str> = head.lines().filter(|l| l.ends_with(" GA")).map(|l| l.split(' ').nth(5).unwrap_or("")).collect(); + assert_eq!(marks, ["1", "1", "1"], "{head}"); + } + + /// A table's cells go fifteen to a record, with one string at most in each. + #[test] + fn cells_go_fifteen_to_a_record_with_one_string_each() { + let mut out = String::new(); + let mut t = Table::new(&mut out); + t.text("a"); + t.text("b"); + for v in 0..16 { + t.num(f64::from(v)); + } + t.finish(); + let records: Vec<&str> = out.lines().collect(); + assert_eq!(records[0], "%_(a) Gc"); + assert_eq!(records[1], "%_(b) 0 1 2 3 4 5 6 7 8 9 10 11 12 13 Gc"); + assert_eq!(records[2], "%_14 15 Gc"); + } +} diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 8b081782d3..22a9833e07 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -10329,11 +10329,14 @@ fn a_brushed_path_with_samples_of_the_pen_keeps_its_drawn_look() { assert_eq!(a.len(), 1, "{a:#?}"); assert!(matches!(a[0].kind, NodeKind::Group { .. }), "the drawn look: {a:#?}"); assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); - // The samples belong to the path before them: a path after them is read as it was. - let after = format!("{path}{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)); + // The samples belong to the path before them: a path after them with the same brush and no + // samples of its own comes in brushed. + let after = format!("{path}{}", brushed_path()); let a = art(&read_ok(&format!("{def}{style}{}", layer(&after))).doc); assert_eq!(a.len(), 2, "{a:#?}"); - assert!(matches!(a[1].kind, NodeKind::Path { .. }) && a[1].appearance.fill().is_some(), "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "the sampled path's drawn look: {a:#?}"); + assert!(matches!(a[1].kind, NodeKind::Path { .. }), "the path after the samples, brushed: {a:#?}"); + assert_eq!(a[1].appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Six D"), "{a:#?}"); // Samples with no object before them in the layer mark nothing: the path after them is read. let first = format!("{PEN_SAMPLES}{}", brushed_path()); let a = art(&read_ok(&format!("{def}{style}{}", layer(&first))).doc); diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 76980f4b7e..779385b82b 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -43,13 +43,17 @@ //! PostScript names in `%%DocumentFonts` and `%%IncludeFont` (§3.1) and re-encoded to //! Windows-1252 (`TE`, then `%AI3_BeginEncoding: _name name`, `TZ`, §3.3), the 8-bit encoding //! strings are in. +//! - Graphs (§17): a graph whose records VectorCraft's reader reads back as the same graph +//! (`crate::ai_graph`) as `%_Gs`, its functional records, its group with each series' group +//! followed by `%_2 column 0 0 Go`, and `%_GS`. //! //! The format has one artboard, no transparency, sublayers, object names, hidden objects (only //! layers hide), live effects, symbols or appearances with several fills and strokes. Callers write //! one file per artboard and expand appearances first; what is left is written as the format can //! hold it, each loss a warning: transparent objects are written opaque, sublayers become groups, -//! live objects and symbols their art, strokes a plain line can't draw their outlines, pattern -//! fills their tiles clipped to the shape, freeform gradients their average colour, elliptical +//! live objects and symbols their art, other graphs their art, strokes a plain line can't draw +//! their outlines, pattern fills their tiles clipped to the shape, freeform gradients their average +//! colour, elliptical //! radial gradients circular (the gradient matrix is relative to a canvas the specification //! doesn't size), transparent image pixels over white. Hidden objects go to a hidden layer of //! their own above their layer, are left out or show, as [`HiddenObjects`] says (in a hidden diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index ad27eb4a03..056db02074 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -2157,3 +2157,64 @@ fn a_stained_instance_is_written_in_its_stained_color() { let text = String::from_utf8(write(&doc).bytes).unwrap(); assert!(text.contains("0 0 1 Xa") && !text.contains("1 0 0 Xa"), "{text}"); } + +/// A graph whose records read back whole is written with them (§17), and opens again as the same +/// live graph: its table, type and settings, its series' groups tagged in order, without a warning +/// about graphs. +#[test] +fn graphs_are_written_with_their_records_and_open_live() { + use crate::ai_graph::sample; + for (what, doc) in sample::kept() { + let out = write(&doc); + assert!(!out.warnings.iter().any(|w| w == GRAPHS), "{what}: {:?}", out.warnings); + let text = String::from_utf8(out.bytes.clone()).unwrap(); + assert_eq!((text.matches("%_Gs\n").count(), text.matches("%_GS\n").count()), (1, 1), "{what}: {text}"); + let read = crate::import(&out.bytes).unwrap(); + let written = sample::graphs(&doc); + let graphs = sample::graphs(&read.document); + assert_eq!(graphs.len(), 1, "{what}: {:?}", read.warnings); + assert!(sample::same(&written[0].0, &graphs[0].0), "{what}: {:#?}\n{:#?}", written[0].0, graphs[0].0); + assert_eq!(graphs[0].1, written[0].1, "{what}: the members and their series"); + } +} + +/// A graph inside a group is written with its records too, and opens on its own group. +#[test] +fn a_graph_in_a_group_is_written_with_its_records() { + use crate::ai_graph::sample; + let mut doc = Document::new(400.0, 300.0); + let graph = sample::graph(&mut doc, sample::spec(vectorcraft_doc::GraphKind::Column)); + let frame = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), red(), Paint::None); + let outer = Node::group(doc.alloc_id(), vec![Arc::new(frame), Arc::new(graph)]); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, outer).unwrap(); + let out = write(&doc); + assert!(!out.warnings.iter().any(|w| w == GRAPHS), "{:?}", out.warnings); + let read = crate::import(&out.bytes).unwrap(); + let graphs = sample::graphs(&read.document); + assert_eq!(graphs.len(), 1, "{:?}", read.warnings); + assert!(sample::same(&sample::graphs(&doc)[0].0, &graphs[0].0)); + let layer = children(&read.document.layers[0]); + assert!(layer[0].extras().graph.is_none() && layer[0].children().is_some_and(|c| c.len() == 2), "{layer:#?}"); +} + +/// A graph whose records wouldn't read back as it is is written as its art, without records, with +/// the warning, and opens as that art. +#[test] +fn graphs_the_records_cant_keep_are_written_as_their_art() { + use crate::ai_graph::sample; + for (what, doc) in sample::unkept() { + let out = export_legacy_ai(&doc, &LegacyAiOptions::default()).unwrap(); + assert!(out.warnings.iter().any(|w| w == GRAPHS), "{what}: {:?}", out.warnings); + let text = String::from_utf8_lossy(&out.bytes).into_owned(); + assert!(!text.contains("%_Gs") && !text.contains(" Go\n") && !text.contains("%_GS"), "{what}: {text}"); + let read = crate::import(&out.bytes).unwrap(); + assert!(sample::graphs(&read.document).is_empty(), "{what}"); + let paths = |d: &Document| { + let mut count = 0; + d.walk(|n| count += usize::from(matches!(n.kind, NodeKind::Path { .. }))); + count + }; + assert_eq!(paths(&read.document), paths(&doc), "{what}: the graph's art is kept"); + } +} diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 0b41c3defa..d8f88e0d0b 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -52,6 +52,8 @@ pub(super) struct Writer<'a> { last_image: Option<(ImageBlob, Option>)>, /// The images written whole so far, by the address of their data (which each entry keeps). whole_images: std::collections::HashMap>>, + /// Writing a graph's art with its records: a graph inside it is written as its art. + in_graph: bool, } impl<'a> Writer<'a> { @@ -78,6 +80,7 @@ impl<'a> Writer<'a> { clips: vec![], last_image: None, whole_images: std::collections::HashMap::new(), + in_graph: false, } } @@ -264,7 +267,12 @@ impl<'a> Writer<'a> { if n.name.as_deref().is_some_and(|s| !s.is_empty()) && !n.is_layer() { self.warn(NAMES); } - if n.extras().graph.is_some() { + // A graph on the layers keeps its data and settings when its records read back whole. + let graph = match n.extras().graph.is_some() { + true if self.nest == 0 && !self.in_graph => crate::ai_graph::records(n, self.to_art), + _ => None, + }; + if n.extras().graph.is_some() && graph.is_none() { self.warn(GRAPHS); } let locked = locked || n.locked; @@ -273,7 +281,10 @@ impl<'a> Writer<'a> { self.warn(SUBLAYERS); self.group(children, *clip, locked); } - NodeKind::Group { children, clip } => self.group(children, *clip, locked), + NodeKind::Group { children, clip } => match &graph { + Some(records) => self.graph(children, locked, records), + None => self.group(children, *clip, locked), + }, NodeKind::Path { path, rule, guide, .. } => { self.lock(locked); if *guide { @@ -352,6 +363,25 @@ impl<'a> Writer<'a> { } } + /// A graph's group (`u` … `U`) between its records (§17): `%_Gs` and its functional records + /// before it, `%_GS` after it, and each series' group followed by its `Go` tag. + fn graph(&mut self, children: &[Arc], locked: bool, records: &crate::ai_graph::Records) { + self.lock(locked); + self.body.push_str(&records.head); + self.line("u"); + self.in_graph = true; + for c in children { + self.node(c, locked); + if let Some(tag) = records.tags.get(&c.id) { + self.line(tag); + } + } + self.in_graph = false; + self.lock(locked); + self.line("U"); + self.line(crate::ai_graph::END); + } + /// A clipping group: its first member's outline clips the others (`h W n`). That member's fills /// paint behind the art, inside the clip, and its strokes over it, outside. fn clip_group(&mut self, children: &[Arc], locked: bool) { diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 534778286a..ada3171e0a 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -31,9 +31,11 @@ //! - `ai_editing`: the editing copy of the art a PDF-compatible `.ai` file carries //! ([`export_ai_editing`], [`ai_private_data`]). //! - `ai_ops`: the path and line style operators those two write. +//! - `ai_graph`: the records of a live graph those two write around its group. //! - [`dcs`]: DCS files, an EPS file with one program per printing plate, read for placing. mod ai_editing; +mod ai_graph; mod ai_ops; pub mod dcs; mod import; diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 69c7b5ba0e..8bd8afc2e5 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -81,6 +81,7 @@ pub(crate) fn pointer_kind(k: &str) -> Option { "up" => PointerKind::Up, "move" => PointerKind::Move, "doubleclick" | "dblclick" => PointerKind::DoubleClick, + "tripleclick" => PointerKind::TripleClick, _ => return None, }) } @@ -238,7 +239,7 @@ impl Headless { .map(|(e, backquote)| { let mods = packet_mods(e)?.unwrap_or(base); let kind = s(e, "kind").unwrap_or(""); - let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick)"))?; + let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick|tripleclick)"))?; PointerEvent::json_check(e)?; if s(e, "space") == Some("screen") { return Err("screen-space pointer events need the desktop app; use document coordinates".to_string()); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 1502f11662..b5fd1135aa 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -16,6 +16,7 @@ mod gradient; mod limits; mod links; mod liquify; +mod livepaint; mod pen; mod persp; mod place; diff --git a/crates/mcp/src/tests/livepaint.rs b/crates/mcp/src/tests/livepaint.rs new file mode 100644 index 0000000000..0165823e6f --- /dev/null +++ b/crates/mcp/src/tests/livepaint.rs @@ -0,0 +1,73 @@ +//! The Live Paint Bucket through MCP: `pointer_gesture` sends a triple-click as `tripleclick`. + +use serde_json::{Value, json}; + +use crate::{Backend, Headless, call_tool}; + +fn ok(h: &mut Headless, name: &str, args: Value) -> Value { + let r = call_tool(h, name, &args); + assert!(!r.is_error, "{name} {args}: {r:?}"); + serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap_or(Value::Null) +} + +fn run(h: &mut Headless, command: &str, params: Value) -> Value { + ok(h, "run_command", json!({"command": command, "params": params})) +} + +/// The fill of each face of Live Paint group `g`, left to right. +fn fills(h: &mut Headless, g: &Value) -> Vec { + let info = run(h, "livePaint.info", json!({"group": g})); + let mut faces = info["faces"].as_array().unwrap().clone(); + faces.sort_by(|a, b| a["bounds"][0].as_f64().unwrap().total_cmp(&b["bounds"][0].as_f64().unwrap())); + faces.iter().map(|f| f["fill"].clone()).collect() +} + +#[test] +fn a_triple_click_fills_every_face_of_the_same_fill() { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 300, "height": 300}})).unwrap(); + let a = run(&mut h, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone(); + let b = run(&mut h, "shape.rectangle", json!({"x": 100, "y": 0, "width": 50, "height": 50}))["id"].clone(); + let g = run(&mut h, "livePaint.make", json!({"ids": [a, b]}))["id"].clone(); + for x in [25, 125] { + run(&mut h, "livePaint.fill", json!({"group": g, "point": [x, 25], "color": "#ff0000"})); + } + let red = fills(&mut h, &g); + assert_eq!(red[0], red[1]); + // With nothing selected, the current fill changes and the group keeps its faces' fills. + run(&mut h, "select.none", json!({})); + run(&mut h, "paint.setFill", json!({"color": "#00ff00"})); + assert_eq!(fills(&mut h, &g), red); + let ev = |kind: &str| json!({"kind": kind, "x": 25, "y": 25}); + let clicks = [ev("down"), ev("up"), ev("down"), ev("up"), ev("doubleclick"), ev("down"), ev("up")]; + ok(&mut h, "pointer_gesture", json!({"tool": "livePaintBucket", "events": clicks})); + let after_two = fills(&mut h, &g); + assert_eq!(after_two[1], red[1], "the face apart is left by the clicks and the double-click"); + assert_ne!(after_two[0], red[0]); + ok(&mut h, "pointer_gesture", json!({"events": [ev("tripleclick")]})); + assert_eq!(fills(&mut h, &g), [after_two[0].clone(), after_two[0].clone()], "both faces were red"); +} + +/// With Space held (`mods.space`), the Live Paint Bucket's clicks, double-click and triple-click +/// paint nothing. +#[test] +fn clicks_with_space_held_paint_nothing() { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 300, "height": 300}})).unwrap(); + let a = run(&mut h, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone(); + let b = run(&mut h, "shape.rectangle", json!({"x": 100, "y": 0, "width": 50, "height": 50}))["id"].clone(); + let g = run(&mut h, "livePaint.make", json!({"ids": [a, b]}))["id"].clone(); + for x in [25, 125] { + run(&mut h, "livePaint.fill", json!({"group": g, "point": [x, 25], "color": "#ff0000"})); + } + run(&mut h, "select.none", json!({})); + run(&mut h, "paint.setFill", json!({"color": "#00ff00"})); + let red = fills(&mut h, &g); + let ev = |kind: &str| json!({"kind": kind, "x": 25, "y": 25}); + let clicks = [ev("down"), ev("up"), ev("down"), ev("up"), ev("doubleclick"), ev("down"), ev("up"), ev("tripleclick")]; + ok(&mut h, "pointer_gesture", json!({"tool": "livePaintBucket", "mods": {"space": true}, "events": clicks})); + assert_eq!(fills(&mut h, &g), red); + // Without Space, the same clicks paint. + ok(&mut h, "pointer_gesture", json!({"events": [ev("down"), ev("up")]})); + assert_ne!(fills(&mut h, &g)[0], red[0]); +} diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index e7ba79df7c..4b55d6aff1 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -205,7 +205,7 @@ pub fn tool_definitions() -> Vec { "type": "array", "minItems": 1, "items": obj(json!({ - "kind": {"type": "string", "enum": ["down", "drag", "up", "move", "doubleclick"]}, + "kind": {"type": "string", "enum": ["down", "drag", "up", "move", "doubleclick", "tripleclick"]}, "x": num("Document x (pt)"), "y": num("Document y (pt)"), "mods": mods_schema(), @@ -465,7 +465,7 @@ pub fn tool_definitions() -> Vec { tool( "transform", "Transform", - "Move, rotate, scale, reflect or shear the selection (or `ids`); several may be combined and run in that order. Angles in degrees (counter-clockwise), scale in %. `origin` [x,y] defaults to the selection centre; `copy` transforms a copy.", + "Move, rotate, scale, reflect or shear the selection (or `ids`); several may be combined and run in that order. Angles in degrees (counter-clockwise), scale in %. Scale and reflect act along the selection's bounding box, turned with rotated objects until Reset Bounding Box. `origin` [x,y] defaults to the selection centre; `copy` transforms a copy.", obj( json!({ "ids": {"type": "array", "items": {"type": "integer"}}, diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs index 8d410af183..8b9db99564 100644 --- a/crates/mcp/src/tools/dispatch.rs +++ b/crates/mcp/src/tools/dispatch.rs @@ -145,7 +145,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result -pub const EXTRA_FEATURES: [&str; 29] = [ +/// +/// +pub const EXTRA_FEATURES: [&str; 31] = [ "ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09", "ss10", "ss11", "ss12", "ss13", "ss14", "ss15", "ss16", "ss17", "ss18", - "ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs", "numr", "dnom", "aalt", + "ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs", "numr", "dnom", "aalt", "hkna", "ital", ]; +/// The features of [`EXTRA_FEATURES`] that are only on or off: they substitute one form for each +/// glyph, so they take no alternate's number (`hkna=2` is refused). +const SWITCH_FEATURES: [&str; 2] = ["hkna", "ital"]; + impl Default for OtFeatures { fn default() -> Self { Self { @@ -107,10 +114,11 @@ struct ParsedTag<'a> { value: u16, } -/// Can feature `name` take a value (`salt=2`)? The features of [`EXTRA_FEATURES`] and `swsh` can: -/// each substitutes glyphs, and the value says which alternate. +/// Can feature `name` take a value (`salt=2`)? The features of [`EXTRA_FEATURES`] but +/// [`SWITCH_FEATURES`], and `swsh`, can: each substitutes glyphs, and the value says which +/// alternate. fn takes_value(name: &str) -> bool { - name == "swsh" || EXTRA_FEATURES.contains(&name) + name == "swsh" || (EXTRA_FEATURES.contains(&name) && !SWITCH_FEATURES.contains(&name)) } /// Read a feature tag: `tag`, `+tag`, `-tag` or, for a feature that takes a value, `tag=N` (`N` @@ -316,6 +324,8 @@ impl OtFeatures { } for (i, tag) in EXTRA_FEATURES.iter().enumerate() { if let (true, Ok(t)) = (s.extra & (1 << i) != 0, <[u8; 4]>::try_from(tag.as_bytes())) { + // H or V Style: the kana forms made for the writing direction, `vkna` in vertical type. + let t = if self.vertical && &t == b"hkna" { *b"vkna" } else { t }; v.push(f(&t, u32::from(s.extra_values.get(i).copied().unwrap_or(0).max(1)))); } } @@ -434,6 +444,31 @@ mod tests { } } + /// H or V Style (`hkna`) and Roman Italics (`ital`) are known, round-trip through the stored + /// tags and reach the shaper: `hkna` as itself in horizontal type and as `vkna` in vertical + /// type, `ital` in both. Neither takes an alternate's number. + #[test] + fn kana_style_and_roman_italics_are_kept_and_shaped() { + for t in ["hkna", "-hkna", "+ital", "ital"] { + assert!(OtFeatures::known_tag(t), "{t}"); + } + for t in ["hkna=2", "ital=0", "vkna"] { + assert!(!OtFeatures::known_tag(t), "{t}"); + } + assert_eq!(OtFeatures::from_tags(["ital", "hkna", "smcp"]).to_tags(), ["smcp", "hkna", "ital"]); + assert_eq!(OtFeatures::from_tags(["hkna", "ital", "-hkna"]).to_tags(), ["ital"]); + let st = CharStyle { features: vec!["hkna".into(), "ital".into()], ..CharStyle::default() }; + let on = |vertical: bool| -> Vec { + OtFeatures { vertical, ..OtFeatures::default() }.resolve(&st).iter().filter(|f| f.value == 1).map(|f| f.tag.to_string()).collect() + }; + assert_eq!(on(false), ["hkna", "ital"]); + assert_eq!(on(true), ["vkna", "ital", "vert"]); + let plain = |vertical: bool| OtFeatures { vertical, ..OtFeatures::default() }.resolve(&CharStyle::default()); + for vertical in [false, true] { + assert!(!plain(vertical).iter().any(|f| [b"hkna", b"vkna", b"ital"].iter().any(|t| f.tag == Tag::new(t))), "off by default"); + } + } + /// `changes` lists the features a change turns on and off and leaves out the ones it keeps, /// and applying them to any set makes the same change. #[test] diff --git a/crates/tools/src/addons/corner_tool.rs b/crates/tools/src/addons/corner_tool.rs index 177926837e..470795579e 100644 --- a/crates/tools/src/addons/corner_tool.rs +++ b/crates/tools/src/addons/corner_tool.rs @@ -311,6 +311,7 @@ impl Tool for CornerTool { None => vec![], } } + PointerKind::TripleClick => vec![], } } diff --git a/crates/tools/src/addons/extend_path.rs b/crates/tools/src/addons/extend_path.rs index d1f54861ca..2269396f90 100644 --- a/crates/tools/src/addons/extend_path.rs +++ b/crates/tools/src/addons/extend_path.rs @@ -131,7 +131,7 @@ impl Tool for ExtendPathTool { // Back where it started: nothing to keep. if length == 0.0 { vec![Action::Cancel] } else { vec![preview, Action::Commit] } } - PointerKind::DoubleClick => vec![], + PointerKind::DoubleClick | PointerKind::TripleClick => vec![], } } diff --git a/crates/tools/src/addons/parametric_shape.rs b/crates/tools/src/addons/parametric_shape.rs index 68a42a665f..33700073c7 100644 --- a/crates/tools/src/addons/parametric_shape.rs +++ b/crates/tools/src/addons/parametric_shape.rs @@ -326,7 +326,7 @@ impl Tool for ParametricShapeTool { self.hover(cx, ev.pos); } match ev.kind { - PointerKind::Move => vec![], + PointerKind::Move | PointerKind::TripleClick => vec![], PointerKind::Down => { let tol = cx.point_tol(); let grip = selected(cx).and_then(|(id, s)| { diff --git a/crates/tools/src/addons/slide_anchor.rs b/crates/tools/src/addons/slide_anchor.rs index e24db4c50d..0bec99cf35 100644 --- a/crates/tools/src/addons/slide_anchor.rs +++ b/crates/tools/src/addons/slide_anchor.rs @@ -96,7 +96,7 @@ impl Tool for SlideAnchorTool { // Back where it started: nothing to keep. if t == 0.0 { vec![Action::Cancel] } else { vec![preview, Action::Commit] } } - PointerKind::DoubleClick => vec![], + PointerKind::DoubleClick | PointerKind::TripleClick => vec![], } } diff --git a/crates/tools/src/addons/smart_pen.rs b/crates/tools/src/addons/smart_pen.rs index cd72f1dd40..c25ab8f670 100644 --- a/crates/tools/src/addons/smart_pen.rs +++ b/crates/tools/src/addons/smart_pen.rs @@ -527,6 +527,7 @@ impl Tool for SmartPenTool { } _ => vec![], }, + PointerKind::TripleClick => vec![], } } diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 2ad8ade0e4..00005e3c21 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -42,8 +42,8 @@ pub use live_paint::{ use live_paint::{check_live_paint_options, lp_color, lp_rgb_color}; pub use lookup::{ EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, LP_JOIN_TOL, SOURCES_NAME, builder_members, connected_edges, drawn_outlines, - edge_near, edges, face_at, faces, flood_faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, - path_contains, shape_builder_shapes, shapes_for, sources, visible_edge_near, visible_face_at, + edge_near, edges, face_at, faces, flood_faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, lp_stroke_paint, node_outline, + outline_snap, path_contains, shape_builder_shapes, shapes_for, sources, visible_edge_near, visible_face_at, }; pub use map::{ BuilderMap, HATCH, HIGHLIGHT_RED, Hit, MergeGeometry, MergeInput, PreparedMerge, Rest, Touched, hatch, maps_built, merge_geometry, merge_inputs, diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index b5472659d5..235715a0b6 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -1,12 +1,14 @@ use super::cache::RegionCache; use super::lookup::{ - connected_edges, flood_faces, is_live_paint, live_paint_at, live_paint_at_where, lp_stroke_paint, visible_edge_near, visible_face_at, + connected_edges, flood_faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_at_where, lp_stroke_paint, node_outline, visible_edge_near, + visible_face_at, }; use super::map::{HIGHLIGHT_RED, sorted_roots}; use super::{ Action, Arc, BezPath, CLICK_TOL_PX, Cursor, Document, EDGE_NAME, FACE_NAME, FillRule, Mods, Node, NodeId, Overlay, Point, PointerEvent, PointerKind, Rect, Tool, ToolContext, ToolKey, Value, color_swatches, json, json_ids, path_contains, pick_swatch, plain, swatch_chips, }; +use vectorcraft_color::Paint; // ---------- Live Paint Bucket ---------- @@ -174,6 +176,17 @@ enum LpPaint { /// highlighted. The cursor is a filled bucket where a click fills a face, a brush where it paints /// an edge and an empty bucket where it paints nothing; with Alt it is the Eyedropper. /// +/// A double-click on a face fills it and the faces reached from it across edges that draw no +/// stroke (`scope: "connected"`); on an edge it paints the edges joined to it that have its stroke. +/// A triple-click on a face fills every face of the group with the face's fill, and on an edge it +/// paints every edge of the group with the edge's stroke (`scope: "samePaint"`). Each click of a +/// double-click or triple-click paints as it arrives, as its own undo step, and the paint compared +/// is the one the face or edge had before the first of its clicks that painted it. A first click +/// that made the Live Paint group counts when the face it filled is the clicked face. Shift and the +/// options choose a face or an edge as for a click; with Alt or Cmd/Ctrl held a double-click or +/// triple-click paints nothing. With Space held for the view tools, a click, double-click or triple-click paints +/// nothing and no face or edge is highlighted. +/// /// The Left and Right arrow keys without Shift, Alt or Cmd/Ctrl set the active paint (the fill, /// or the stroke when the Stroke proxy is in front) to the color swatch before or after it in the /// Swatches panel, wrapping around; from a color that is no swatch, Right picks the first swatch @@ -184,10 +197,13 @@ enum LpPaint { pub struct LivePaintBucketTool { pub(super) cache: RegionCache, pub(super) hover: LpHover, - /// Where the pointer last moved, and whether Shift and Alt were held. - at: Option<(Point, bool, bool)>, + /// Where the pointer last moved, and the modifiers held there. + at: Option<(Point, Mods)>, /// The fill command and polyline previewed from the press's snapshot. fill_drag: Option<(Value, Vec>)>, + /// The last [`LP_PRESSES`] presses, oldest first: the face or edge each one painted, with its + /// paint before that press, or None for a press that painted no face or edge. + presses: Vec>, fills: bool, strokes: bool, highlight: bool, @@ -200,6 +216,40 @@ pub struct LivePaintBucketTool { picked: [Option; 2], } +/// The face or edge a Live Paint Bucket press paints. +#[derive(Clone, Debug, PartialEq)] +struct LpPress { + /// The face or edge; None for the face of the Live Paint group the press makes. + part: Option, + /// Where the press was. + at: Point, + /// Its fill (a face), or the stroke paint it draws with ([`lp_stroke_paint`], an edge), before + /// the press. + before: Paint, + /// The other faces or edges the double-click that followed the press reached, each with its + /// fill or drawn stroke paint before the double-click. + also: Vec<(NodeId, Paint)>, +} + +/// The fill of the face under `p` in the Live Paint group that `roots` make: the fill of the +/// front-most of their paths whose closed subpaths hold `p`, else none. Open subpaths bound no face. +fn made_face_fill(doc: &Document, roots: &[NodeId], p: Point) -> Paint { + let mut leaves = vec![]; + for n in roots.iter().filter_map(|id| doc.node(*id)) { + leaf_shapes(n, &mut leaves); + } + let holds = |l: &Node| { + node_outline(l).is_some_and(|(path, rule)| { + let closed = vectorcraft_geom::PathData::new(path.subpaths.into_iter().filter(|sp| sp.closed && sp.anchors.len() >= 2).collect()); + !closed.is_empty() && path_contains(&closed, rule, p) + }) + }; + leaves.iter().rev().find(|l| holds(l)).map_or(Paint::None, |l| l.appearance.fill_paint()) +} + +/// How many presses the Live Paint Bucket keeps: the three of a triple-click. +const LP_PRESSES: usize = 3; + /// Maximum points in one Live Paint fill path chunk. pub const LP_FILL_CHUNK_POINTS: usize = 4096; @@ -210,6 +260,7 @@ impl Default for LivePaintBucketTool { hover: LpHover::None, at: None, fill_drag: None, + presses: Vec::new(), fills: true, strokes: false, highlight: true, @@ -274,14 +325,109 @@ impl LivePaintBucketTool { } } - /// The highlight at `p` for a pointer with Shift and Alt held as given: none with Alt or with - /// Highlight off, else the face or edge a click there paints. With Highlight off the selection's - /// map is still made, for the cursor ([`Self::paints_at`]). - fn hover_at(&mut self, cx: &ToolContext, p: Point, shift: bool, alt: bool) -> LpHover { - if alt { + /// Note a press: what it paints, None when it paints no face or edge. + fn note_press(&mut self, press: Option) { + if self.presses.len() == LP_PRESSES { + self.presses.remove(0); + } + self.presses.push(press); + } + + /// Note a press at `at` that paints face or edge (`edge`) `n`. + fn note_part(&mut self, n: &Node, edge: bool, at: Point) { + let before = if edge { lp_stroke_paint(n) } else { n.appearance.fill_paint() }; + self.note_press(Some(LpPress { part: Some(n.id), at, before, also: vec![] })); + } + + /// Note that the double-click after the last press also reaches `parts` of group `group`, with + /// the paint each has now (a face's fill, or an edge's drawn stroke paint with `edge`). + fn note_reached(&mut self, group: &Node, parts: Vec, edge: bool) { + let Some(Some(last)) = self.presses.last_mut() else { return }; + let reached: std::collections::HashSet = parts.into_iter().filter(|id| last.part != Some(*id)).collect(); + last.also = group + .children() + .into_iter() + .flatten() + .filter(|c| reached.contains(&c.id)) + .map(|n| (n.id, if edge { lp_stroke_paint(n) } else { n.appearance.fill_paint() })) + .collect(); + } + + /// The paint `part` had before the first press of a click sequence of `clicks` presses that + /// painted `part`, or before the double-click after a press when that double-click reached it + /// (the sequence's first press counts when it made the group `part` is in and the face it + /// filled there is `part`); `now` when the last press did not paint it. + fn before(&self, cx: &ToolContext, part: NodeId, clicks: usize, now: Paint) -> Paint { + let Some(run) = self.presses.len().checked_sub(clicks).and_then(|from| self.presses.get(from..)) else { return now }; + let made_part = |q: &LpPress| live_paint_at(cx.doc, q.at).and_then(|g| visible_face_at(cx.doc.node(g)?, q.at)).is_some_and(|f| f.id == part); + let on_part = |i: usize, q: &LpPress| q.part == Some(part) || (i == 0 && q.part.is_none() && made_part(q)); + if !run.last().and_then(Option::as_ref).is_some_and(|q| on_part(run.len() - 1, q)) { + return now; + } + for (i, q) in run.iter().enumerate() { + let Some(q) = q else { continue }; + if on_part(i, q) { + return q.before.clone(); + } + if let Some((_, paint)) = q.also.iter().find(|(id, _)| *id == part) { + return paint.clone(); + } + } + now + } + + /// What a double-click (`scope` "connected", 2 `clicks`) or a triple-click ("samePaint", 3) at + /// `p` runs: the command a click there runs, with `scope`, and the paint it compares as `match`. + /// A double-click notes the other faces or edges it reaches, for the triple-click's `match`. + fn multi_click(&mut self, cx: &ToolContext, p: Point, mods: Mods, scope: &str, clicks: usize) -> Vec { + // With Alt held, with Space held for the view tools, or with Cmd/Ctrl held (its presses went + // to the selection tool Cmd lends), a double-click or triple-click paints nothing. + if mods.alt || mods.space || mods.cmd { + return vec![]; + } + let point = json!([p.x, p.y]); + let paint = self.paints(mods.shift); + if matches!(paint, LpPaint::Strokes | LpPaint::Both) + && let Some((g, Some(e))) = lp_part_at(cx.doc, p, cx.tol(4.0), true, &|id| lp_visible(cx, id)) + { + let mut v = json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0), "scope": scope }); + let like = self.before(cx, e.id, clicks, lp_stroke_paint(e)); + if clicks == 2 + && let Some(group) = cx.doc.node(g) + { + self.note_reached(group, connected_edges(group, e.id, &like), true); + } + if let Ok(like) = serde_json::to_value(like) { + v["match"] = like; + } + return vec![Action::Exec("livePaint.strokeEdge".into(), v)]; + } + if !matches!(paint, LpPaint::Fills | LpPaint::Both) { + return vec![]; + } + let Some((g, f)) = live_paint_at(cx.doc, p).and_then(|g| Some((g, visible_face_at(cx.doc.node(g)?, p)?))) else { return vec![] }; + let mut v = json!({ "group": g.0, "point": point, "scope": scope }); + if clicks == 2 + && let Some(group) = cx.doc.node(g) + { + self.note_reached(group, flood_faces(group, f.id), false); + } + if scope == "samePaint" + && let Ok(like) = serde_json::to_value(self.before(cx, f.id, clicks, f.appearance.fill_paint())) + { + v["match"] = like; + } + vec![Action::Exec("livePaint.fill".into(), v)] + } + + /// The highlight at `p` for a pointer with the modifiers `mods`: none with Alt, with Space held + /// for the view tools or with Highlight off, else the face or edge a click there paints. With + /// Highlight off the selection's map is still made, for the cursor ([`Self::paints_at`]). + fn hover_at(&mut self, cx: &ToolContext, p: Point, mods: Mods) -> LpHover { + if mods.alt || mods.space { return LpHover::None; } - let target = self.target_at(cx, p, shift); + let target = self.target_at(cx, p, mods.shift); if self.highlight { target } else { LpHover::None } } @@ -356,13 +502,19 @@ impl Tool for LivePaintBucketTool { } match ev.kind { PointerKind::Move => { - self.at = Some((p, ev.mods.shift, ev.mods.alt)); - self.hover = self.hover_at(cx, p, ev.mods.shift, ev.mods.alt); + self.at = Some((p, ev.mods)); + self.hover = self.hover_at(cx, p, ev.mods); vec![] } PointerKind::Down => { self.hover = LpHover::None; + // With Space held for the view tools, a press paints nothing. + if ev.mods.space { + self.note_press(None); + return vec![]; + } if ev.mods.alt { + self.note_press(None); 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": [] }))] @@ -387,36 +539,49 @@ impl Tool for LivePaintBucketTool { let point = json!([p.x, p.y]); let paint = self.paints(ev.mods.shift); if matches!(paint, LpPaint::Strokes | LpPaint::Both) - && let Some((g, Some(_))) = lp_part_at(cx.doc, p, cx.tol(4.0), true, &|id| lp_visible(cx, id)) + && let Some((g, Some(e))) = lp_part_at(cx.doc, p, cx.tol(4.0), true, &|id| lp_visible(cx, id)) { + self.note_part(e, true, p); return vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0) }))]; } if !matches!(paint, LpPaint::Fills | LpPaint::Both) { + self.note_press(None); return vec![]; } if let Some(g) = live_paint_at(cx.doc, p) { + match cx.doc.node(g).and_then(|n| visible_face_at(n, p)) { + Some(f) => self.note_part(f, false, p), + None => self.note_press(None), + } return self.begin_fill(json!({ "group": g.0, "point": point }), p); } if self.cache.at(cx, p).is_some() { let roots = sorted_roots(cx.doc, &cx.selection.objects); + self.note_press(Some(LpPress { part: None, at: p, before: made_face_fill(cx.doc, &roots, p), also: vec![] })); return self.begin_fill(json!({ "ids": json_ids(&roots), "point": point }), p); } + self.note_press(None); vec![] } PointerKind::Drag => self.drag_fill(p), PointerKind::Up => { // The swatch preview shows again at the release point. - self.at = Some((p, ev.mods.shift, ev.mods.alt)); + self.at = Some((p, ev.mods)); let mut acts = self.drag_fill(p); if self.fill_drag.take().is_some() { acts.push(Action::Commit); } acts } - _ => vec![], + PointerKind::DoubleClick => self.multi_click(cx, p, ev.mods, "connected", 2), + PointerKind::TripleClick => self.multi_click(cx, p, ev.mods, "samePaint", 3), } } + fn history_moved(&mut self) { + self.presses.clear(); + } + /// Escape during a fill drag cancels it. Left and Right pick the color swatch before or after /// the active paint, while no drag is on. fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { @@ -455,6 +620,7 @@ impl Tool for LivePaintBucketTool { } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + self.presses.clear(); if self.fill_drag.take().is_some() { vec![Action::Commit] } else { vec![] } } @@ -469,7 +635,8 @@ impl Tool for LivePaintBucketTool { // No chips during a fill drag, or while Alt makes the tool sample with the eyedropper. if self.preview && !self.busy() - && let Some((at, _, false)) = self.at + && let Some((at, mods)) = self.at + && !mods.alt { out.extend(swatch_chips(cx, at, Self::active_paint(cx), self.picked[Self::picked_slot(cx)], true)); } @@ -520,8 +687,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, alt)) = self.at { - self.hover = self.hover_at(cx, p, shift, alt); + if let Some((p, mods)) = self.at { + self.hover = self.hover_at(cx, p, mods); } if !wait || !self.cache.waiting() { break; @@ -776,6 +943,9 @@ impl Tool for LivePaintSelectionTool { (false, false) => vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))], } } + // The tool takes no action on a triple-click; its third press and release select as a + // click does. + PointerKind::TripleClick => vec![], } } diff --git a/crates/tools/src/builder/lookup.rs b/crates/tools/src/builder/lookup.rs index 8e17823332..daa9a34f82 100644 --- a/crates/tools/src/builder/lookup.rs +++ b/crates/tools/src/builder/lookup.rs @@ -130,7 +130,7 @@ fn lp_cells_around(p: Point) -> impl Iterator { /// The stroke paint Live Paint edge `edge` draws with: the paint of its topmost stroke that is shown /// with a paint, a weight above 0 and an opacity above 0, or [`Paint::None`] when the edge is fully /// transparent or has no such stroke. -pub(super) fn lp_stroke_paint(edge: &Node) -> Paint { +pub fn lp_stroke_paint(edge: &Node) -> Paint { if edge.opacity <= 0.0 { return Paint::None; } diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 4c9da58425..66db1e550c 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1124,6 +1124,287 @@ fn bucket_click_fills_face_and_shift_click_strokes_edge() { assert_eq!(id, "livePaint.strokeEdge"); } +/// A double-click runs a click's command with `scope: "connected"` and a triple-click with +/// `"samePaint"`, matching the paint the face or edge had before the first click there. +#[test] +fn bucket_double_and_triple_click_widen_what_a_click_paints() { + let (d, gid, f2, e) = live_group_doc(); + let red = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + let mut painted = d.clone(); + painted.node_mut(f2).unwrap().appearance.set_fill(red.clone()); + painted.node_mut(e).unwrap().appearance.set_stroke(red.clone()); + let s = Selection::default(); + let p = paint(); + let (cx, later) = (cx(&d, &s, &p), cx(&painted, &s, &p)); + let face_before = serde_json::to_value(d.node(f2).unwrap().appearance.fill_paint()).unwrap(); + let edge_before = serde_json::to_value(d.node(e).unwrap().appearance.stroke_paint()).unwrap(); + let red_json = serde_json::to_value(&red).unwrap(); + assert_ne!(face_before, red_json); + assert_ne!(edge_before, red_json); + let fill = |scope: &str, like: Option<&Value>| { + let mut v = json!({ "group": gid.0, "point": [75.0, 25.0], "scope": scope }); + if let Some(like) = like { + v["match"] = like.clone(); + } + vec![Action::Exec("livePaint.fill".into(), v)] + }; + let click = |t: &mut LivePaintBucketTool, cx: &ToolContext, x: f64, m: Mods| { + t.pointer(cx, &ev(PointerKind::Down, x, 25.0).with_mods(m)); + t.pointer(cx, &ev(PointerKind::Up, x, 25.0).with_mods(m)); + }; + let mut t = LivePaintBucketTool::default(); + // The clicks paint the face as they arrive; the triple-click matches the fill from before them. + click(&mut t, &cx, 75.0, Mods::default()); + click(&mut t, &later, 75.0, Mods::default()); + assert_eq!(t.pointer(&later, &ev(PointerKind::DoubleClick, 75.0, 25.0)), fill("connected", None)); + click(&mut t, &later, 75.0, Mods::default()); + assert_eq!(t.pointer(&later, &ev(PointerKind::TripleClick, 75.0, 25.0)), fill("samePaint", Some(&face_before))); + // A new click there starts again from the fill it finds. + click(&mut t, &later, 75.0, Mods::default()); + assert_eq!(t.pointer(&later, &ev(PointerKind::TripleClick, 75.0, 25.0)), fill("samePaint", Some(&red_json))); + // A click before the triple-click's own three is not part of it. + let mut t = LivePaintBucketTool::default(); + click(&mut t, &cx, 75.0, Mods::default()); + for _ in 0..3 { + click(&mut t, &later, 75.0, Mods::default()); + } + assert_eq!(t.pointer(&later, &ev(PointerKind::TripleClick, 75.0, 25.0)), fill("samePaint", Some(&red_json))); + // Shift: the edge, matching its stroke from before the first click. + let shift = Mods { shift: true, ..Default::default() }; + let stroke = |scope: &str, like: &Value| { + vec![Action::Exec( + "livePaint.strokeEdge".into(), + json!({ "group": gid.0, "point": [51.0, 25.0], "tolerance": 4.0, "scope": scope, "match": like }), + )] + }; + click(&mut t, &cx, 51.0, shift); + click(&mut t, &later, 51.0, shift); + assert_eq!(t.pointer(&later, &ev(PointerKind::DoubleClick, 51.0, 25.0).with_mods(shift)), stroke("connected", &edge_before)); + click(&mut t, &later, 51.0, shift); + assert_eq!(t.pointer(&later, &ev(PointerKind::TripleClick, 51.0, 25.0).with_mods(shift)), stroke("samePaint", &edge_before)); + // With Alt or Space held, or with both options off, a double-click or triple-click paints nothing. + let alt = Mods { alt: true, ..Default::default() }; + let space = Mods { space: true, ..Default::default() }; + for k in [PointerKind::DoubleClick, PointerKind::TripleClick] { + assert!(t.pointer(&cx, &ev(k, 75.0, 25.0).with_mods(alt)).is_empty(), "{k:?}"); + assert!(t.pointer(&cx, &ev(k, 75.0, 25.0).with_mods(Mods { cmd: true, ..Default::default() })).is_empty(), "{k:?} with Cmd"); + assert!(t.pointer(&cx, &ev(k, 75.0, 25.0).with_mods(space)).is_empty(), "{k:?} with Space"); + assert!(t.pointer(&cx, &ev(k, 51.0, 25.0).with_mods(Mods { shift: true, ..space })).is_empty(), "{k:?} with Shift and Space"); + } + // A press with Space held paints nothing either. + for m in [space, Mods { shift: true, ..space }] { + for (k, x) in [(PointerKind::Down, 75.0), (PointerKind::Up, 75.0), (PointerKind::Down, 51.0), (PointerKind::Up, 51.0)] { + assert!(t.pointer(&cx, &ev(k, x, 25.0).with_mods(m)).is_empty(), "{k:?} at {x} with {m:?}"); + } + } + t.set_option("paintFills", &json!(false)); + for k in [PointerKind::DoubleClick, PointerKind::TripleClick] { + assert!(t.pointer(&cx, &ev(k, 75.0, 25.0)).is_empty(), "{k:?}"); + } + // Outside every face, nothing is painted. + t.set_option("paintFills", &json!(true)); + assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 400.0, 400.0)).is_empty()); +} + +/// A triple-click whose third click lands on a face the double-click's flood filled matches that +/// face's fill from before the double-click. +#[test] +fn bucket_triple_click_on_a_face_the_double_click_flooded_matches_its_earlier_fill() { + let (mut d, gid, f2, _) = live_group_doc(); + let red = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + let blue = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + let f1 = crate::builder::face_at(d.node(gid).unwrap(), Point::new(25.0, 25.0)).unwrap().id; + for f in [f1, f2] { + d.node_mut(f).unwrap().appearance.set_fill(red.clone()); + } + let mut first = d.clone(); + first.node_mut(f1).unwrap().appearance.set_fill(blue.clone()); + let mut flooded = first.clone(); + flooded.node_mut(f2).unwrap().appearance.set_fill(blue.clone()); + let s = Selection::default(); + let p = paint(); + let (start, first, flooded) = (cx(&d, &s, &p), cx(&first, &s, &p), cx(&flooded, &s, &p)); + let mut t = LivePaintBucketTool::default(); + let click = |t: &mut LivePaintBucketTool, cx: &ToolContext, x: f64| { + t.pointer(cx, &ev(PointerKind::Down, x, 25.0)); + t.pointer(cx, &ev(PointerKind::Up, x, 25.0)); + }; + click(&mut t, &start, 25.0); + click(&mut t, &first, 25.0); + t.pointer(&first, &ev(PointerKind::DoubleClick, 25.0, 25.0)); + click(&mut t, &flooded, 75.0); + let like = serde_json::to_value(&red).unwrap(); + assert_eq!( + t.pointer(&flooded, &ev(PointerKind::TripleClick, 75.0, 25.0)), + vec![Action::Exec("livePaint.fill".into(), json!({ "group": gid.0, "point": [75.0, 25.0], "scope": "samePaint", "match": like }))] + ); +} + +/// On an edge that stores a stroke paint but draws none (its stroke hidden, or the edge fully +/// transparent), a double-click or triple-click matches no paint, with or without the clicks +/// before it. +#[test] +fn bucket_multi_click_on_an_edge_matches_the_paint_it_draws() { + let (mut d, gid, _, e) = live_group_doc(); + d.node_mut(e).unwrap().appearance.set_stroke(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))); + let mut hidden = d.clone(); + hidden.node_mut(e).unwrap().appearance.stroke_mut().unwrap().visible = false; + let mut clear = d.clone(); + clear.node_mut(e).unwrap().opacity = 0.0; + let none = serde_json::to_value(vectorcraft_color::Paint::None).unwrap(); + let s = Selection::default(); + let p = paint(); + let shift = Mods { shift: true, ..Default::default() }; + let stroke = |scope: &str| { + vec![Action::Exec( + "livePaint.strokeEdge".into(), + json!({ "group": gid.0, "point": [51.0, 25.0], "tolerance": 4.0, "scope": scope, "match": none }), + )] + }; + for doc in [&hidden, &clear] { + let cx = cx(doc, &s, &p); + let mut t = LivePaintBucketTool::default(); + assert_eq!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 51.0, 25.0).with_mods(shift)), stroke("connected")); + for _ in 0..3 { + t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(shift)); + t.pointer(&cx, &ev(PointerKind::Up, 51.0, 25.0).with_mods(shift)); + } + assert_eq!(t.pointer(&cx, &ev(PointerKind::TripleClick, 51.0, 25.0).with_mods(shift)), stroke("samePaint")); + } +} + +/// A triple-click whose first click makes the Live Paint group matches the fill of the face the +/// group makes there: an open path in front, whose outline bounds no face, does not give it. +#[test] +fn bucket_triple_click_on_a_made_group_skips_open_paths() { + use vectorcraft_color::{Color, Paint}; + use vectorcraft_geom::{PathData, SubPath}; + let (red, blue) = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0))); + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let back = d.alloc_id(); + let mut look = Appearance::default_art(); + look.set_fill(red.clone()); + d.insert(Some(l), 0, Node::path(back, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), look)).unwrap(); + // An open U around (75, 25), filled blue. + let u = [Point::new(60.0, 5.0), Point::new(60.0, 45.0), Point::new(90.0, 45.0), Point::new(90.0, 5.0)]; + let front = d.alloc_id(); + let mut look = Appearance::default_art(); + look.set_fill(blue); + d.insert(Some(l), 1, Node::path(front, PathData::single(SubPath::polyline(&u, false)), look)).unwrap(); + // The face the planar map makes there takes its fill from the square, as the group's would. + let shapes: Vec = [back, front] + .iter() + .enumerate() + .filter_map(|(k, id)| node_outline(d.node(*id)?).map(|(path, rule)| vectorcraft_pathops::Shape::new(path, rule, k as u64))) + .collect(); + let (regions, _) = vectorcraft_pathops::live_paint(&shapes); + let face = regions.iter().find(|r| r.contains(Point::new(75.0, 25.0))).expect("a face at (75, 25)"); + assert_eq!(face.top(), Some(0), "the square is the face's top shape"); + let mut s = Selection::default(); + s.set([back, front]); + let p = paint(); + let (made, ..) = live_group_doc(); + let none = Selection::default(); + let (cx, later) = (cx(&d, &s, &p), cx(&made, &none, &p)); + let mut t = LivePaintBucketTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)); + t.pointer(&cx, &ev(PointerKind::Up, 75.0, 25.0)); + for _ in 0..2 { + t.pointer(&later, &ev(PointerKind::Down, 75.0, 25.0)); + t.pointer(&later, &ev(PointerKind::Up, 75.0, 25.0)); + } + let acts = t.pointer(&later, &ev(PointerKind::TripleClick, 75.0, 25.0)); + let [Action::Exec(id, v)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!(id, "livePaint.fill"); + assert_eq!(v["match"], serde_json::to_value(&red).unwrap()); +} + +/// A triple-click whose first click makes the Live Paint group on one face and whose other two +/// clicks fill another face matches the fill the second face had before the first click on it, +/// not the fill under the first click or the fill the clicks gave it. +#[test] +fn bucket_triple_click_after_making_the_group_on_another_face_matches_its_own_face() { + use vectorcraft_color::{Color, Paint}; + let (red, blue) = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0))); + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let square = d.alloc_id(); + let mut look = Appearance::default_art(); + look.set_fill(red.clone()); + d.insert(Some(l), 0, Node::path(square, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), look)).unwrap(); + let mut s = Selection::default(); + s.set([square]); + let p = paint(); + let (made, _, f2, _) = live_group_doc(); + let mut painted = made.clone(); + painted.node_mut(f2).unwrap().appearance.set_fill(blue.clone()); + let none = Selection::default(); + let (cx, later, after) = (cx(&d, &s, &p), cx(&made, &none, &p), cx(&painted, &none, &p)); + let mut t = LivePaintBucketTool::default(); + // The first click makes the group and fills the face at (25, 25); the next two fill (75, 25), + // the first of them painting it blue. + t.pointer(&cx, &ev(PointerKind::Down, 25.0, 25.0)); + t.pointer(&cx, &ev(PointerKind::Up, 25.0, 25.0)); + for ctx in [&later, &after] { + t.pointer(ctx, &ev(PointerKind::Down, 75.0, 25.0)); + t.pointer(ctx, &ev(PointerKind::Up, 75.0, 25.0)); + } + let acts = t.pointer(&after, &ev(PointerKind::TripleClick, 75.0, 25.0)); + let [Action::Exec(id, v)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!(id, "livePaint.fill"); + let own = serde_json::to_value(made.node(f2).unwrap().appearance.fill_paint()).unwrap(); + assert_ne!(own, serde_json::to_value(&red).unwrap()); + assert_ne!(own, serde_json::to_value(&blue).unwrap()); + assert_eq!(v["match"], own); +} + +/// A triple-click whose clicks land on one face, another face, then the first face again matches +/// the fill the first face had before the first click on it, not the fill that click gave it. +#[test] +fn bucket_triple_click_with_a_click_on_another_face_between_matches_the_first_fill() { + use vectorcraft_color::{Color, Paint}; + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let (d, gid, f2, _) = live_group_doc(); + let mut painted = d.clone(); + painted.node_mut(f2).unwrap().appearance.set_fill(red.clone()); + let s = Selection::default(); + let p = paint(); + let (cx, later) = (cx(&d, &s, &p), cx(&painted, &s, &p)); + let mut t = LivePaintBucketTool::default(); + // The first click paints the face at (75, 25) red; the second fills the face at (25, 25). + for (ctx, x) in [(&cx, 75.0), (&later, 25.0), (&later, 75.0)] { + t.pointer(ctx, &ev(PointerKind::Down, x, 25.0)); + t.pointer(ctx, &ev(PointerKind::Up, x, 25.0)); + } + let acts = t.pointer(&later, &ev(PointerKind::TripleClick, 75.0, 25.0)); + let own = serde_json::to_value(d.node(f2).unwrap().appearance.fill_paint()).unwrap(); + assert_ne!(own, serde_json::to_value(&red).unwrap()); + assert_eq!( + acts, + vec![Action::Exec("livePaint.fill".into(), json!({ "group": gid.0, "point": [75.0, 25.0], "scope": "samePaint", "match": own }))] + ); +} + +/// With Space held for the view tools, the pointer outlines no face or edge, as a press there +/// paints nothing. +#[test] +fn bucket_highlights_nothing_with_space_held() { + let (d, ..) = live_group_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = LivePaintBucketTool::default(); + let outlined = |t: &LivePaintBucketTool| t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { .. })); + let space = Mods { space: true, ..Default::default() }; + for (x, m) in [(75.0, space), (51.0, Mods { shift: true, ..space })] { + t.pointer(&cx, &ev(PointerKind::Move, x, 25.0).with_mods(m)); + assert!(!outlined(&t), "at {x} with {m:?}"); + } + t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); + assert!(outlined(&t)); +} + /// After a fill drag, the cursor swatch preview shows at the release point, not where the pointer /// last moved before the press. #[test] @@ -2292,6 +2573,22 @@ fn live_paint_selection_double_click_selects_connected_faces_or_edges() { assert!(dbl(&mut t, 25.0, 1.0, alt).is_empty()); } +#[test] +fn live_paint_selection_triple_click_adds_nothing_to_its_clicks() { + let mut d = Document::new(500.0, 500.0); + let (_, [a, ..], _) = live_strip(&mut d); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = LivePaintSelectionTool::default(); + // The third click's press and release select the face under the pointer, as a click does. + assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 25.0, 25.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [a.0] }))]); + for m in [Mods::default(), Mods { alt: true, ..Default::default() }, Mods { shift: true, ..Default::default() }] { + assert!(t.pointer(&cx, &ev(PointerKind::TripleClick, 25.0, 25.0).with_mods(m)).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::TripleClick, 25.0, 1.0).with_mods(m)).is_empty()); + } +} + #[test] fn live_paint_selection_double_click_skips_locked_groups_and_art_outside_isolation() { let mut d = Document::new(500.0, 500.0); diff --git a/crates/tools/src/cropimage.rs b/crates/tools/src/cropimage.rs index ff716bf171..0245f75112 100644 --- a/crates/tools/src/cropimage.rs +++ b/crates/tools/src/cropimage.rs @@ -169,6 +169,7 @@ impl Tool for CropImageTool { PointerKind::Up => self.drag = None, PointerKind::DoubleClick => return self.apply(cx), PointerKind::Move => self.last = ev.pos, + PointerKind::TripleClick => {} } vec![] } diff --git a/crates/tools/src/distort/puppet.rs b/crates/tools/src/distort/puppet.rs index f141378382..2ca6ec0631 100644 --- a/crates/tools/src/distort/puppet.rs +++ b/crates/tools/src/distort/puppet.rs @@ -422,7 +422,7 @@ impl Tool for PuppetWarpTool { } None => vec![], }, - PointerKind::DoubleClick => vec![], + PointerKind::DoubleClick | PointerKind::TripleClick => vec![], } } /// Delete/Backspace remove the selected pins; with none selected they are the shortcut's diff --git a/crates/tools/src/distort/width.rs b/crates/tools/src/distort/width.rs index e7050d3a79..4ebbac4981 100644 --- a/crates/tools/src/distort/width.rs +++ b/crates/tools/src/distort/width.rs @@ -300,6 +300,7 @@ impl Tool for WidthTool { self.hover = Self::spot_at(cx, p); vec![] } + PointerKind::TripleClick => vec![], PointerKind::DoubleClick => { let Some(spot) = Self::spot_at(cx, p) else { return vec![] }; let Some((w, _)) = Self::hit(cx, &spot, p) else { diff --git a/crates/tools/src/draw2/curvature.rs b/crates/tools/src/draw2/curvature.rs index 02087d997a..06ad1c1230 100644 --- a/crates/tools/src/draw2/curvature.rs +++ b/crates/tools/src/draw2/curvature.rs @@ -653,6 +653,7 @@ impl Tool for CurvatureTool { self.pts[i].1 = !self.pts[i].1; vec![Action::Exec("path.curvature".into(), self.params())] } + PointerKind::TripleClick => vec![], } } fn notify(&mut self, cx: &ToolContext, what: &str) { diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 1bf6a9488b..562654c3c4 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -1564,9 +1564,9 @@ impl Tool for GestureTool { }; let mut area = self.area.take().filter(|a| a.fits(key, &self.points)).unwrap_or_else(|| AreaPreview::new(key, self.points[0])); area.step(&self.points, pressures); - for path in area.paths(&self.points) { - o.push(Overlay::Path { path, color, width: 1.0, dashed: false }); - } + let paths = area.paths(&self.points); + o.extend(paths.settled.into_iter().map(|path| Overlay::SharedPath { path, color, width: 1.0 })); + o.extend(paths.live.into_iter().map(|path| Overlay::Path { path, color, width: 1.0, dashed: false })); self.area_pending.set(area.pending(&self.points)); self.area.set(Some(area)); } else if let Some(pressures) = self.size_pressures() { diff --git a/crates/tools/src/draw2/gesture/area.rs b/crates/tools/src/draw2/gesture/area.rs index 0bc0f13f25..a56322db40 100644 --- a/crates/tools/src/draw2/gesture/area.rs +++ b/crates/tools/src/draw2/gesture/area.rs @@ -13,6 +13,7 @@ //! most [`JOIN_PIECES_PER_CALL`] pieces' worth. The grid holds at most [`LISTINGS`] listings. use std::collections::{BTreeSet, HashMap}; +use std::sync::Arc; use vectorcraft_geom::{Affine, BezPath, Line, ParamCurve, PathData, PathEl, PathSeg, Point, SubPath}; use vectorcraft_pathops as po; @@ -84,10 +85,22 @@ pub(super) struct AreaWork { pub joined: usize, } +/// The paths of the outline to show ([`AreaPreview::paths`]). +pub(super) struct AreaPaths { + /// The settled pieces' outlines, joined [`BLOCK_PIECES`] at a time: a block that did not change + /// is the same shared path as in the call before. + pub settled: Vec>, + /// The growing last piece's outline and the thin line over the points not taken yet, drawn + /// over the settled ones. + pub live: Vec, +} + /// The joined outline of up to [`BLOCK_PIECES`] settled pieces. #[derive(Default)] struct Block { - path: BezPath, + /// Shared with the paths [`AreaPreview::paths`] returned: a new one only when the block is + /// joined again. + path: Arc, /// A piece in it changed since `path` was joined. stale: bool, } @@ -227,8 +240,9 @@ impl AreaPreview { /// The paths to show: the settled pieces' outlines, joined [`BLOCK_PIECES`] at a time, the /// growing last piece's and a thin line over the points of `pts` not taken yet. The joined /// paths with a changed piece are joined again, newest first, at most - /// [`JOIN_PIECES_PER_CALL`] pieces a call; the others show the path last joined. - pub(super) fn paths(&mut self, pts: &[Point]) -> Vec { + /// [`JOIN_PIECES_PER_CALL`] pieces a call; the others show the path last joined, the same + /// shared path each call. + pub(super) fn paths(&mut self, pts: &[Point]) -> AreaPaths { let settled = self.settled_count(); for b in (0..self.blocks.len()).rev() { if self.work.joined >= JOIN_PIECES_PER_CALL { @@ -243,9 +257,10 @@ impl AreaPreview { joined.extend(p.shown.elements().iter().copied()); } self.work.joined += pieces.len(); - self.blocks[b] = Block { path: joined, stale: false }; + self.blocks[b] = Block { path: Arc::new(joined), stale: false }; } - let mut out: Vec = self.blocks.iter().filter(|b| !b.path.elements().is_empty()).map(|b| b.path.clone()).collect(); + let settled_paths = self.blocks.iter().filter(|b| !b.path.elements().is_empty()).map(|b| Arc::clone(&b.path)).collect(); + let mut out = vec![]; if let Some(p) = self.pieces.get(settled).filter(|p| !p.shown.elements().is_empty()) { out.push(p.shown.clone()); } @@ -261,7 +276,7 @@ impl AreaPreview { } out.push(guide); } - out + AreaPaths { settled: settled_paths, live: out } } /// How many pieces no longer grow: all of them but a last one with room for more segments. diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index b62226692a..4db9310c79 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -2450,11 +2450,111 @@ fn the_blob_brush_and_the_eraser_outline_a_long_drag_a_bounded_amount_per_frame( } } +/// The settled paths of the outline of a long Blob Brush or Eraser drag (each the outline of up +/// to `BLOCK_PIECES` pieces) come back from one call to the next, at any zoom, as the same shared +/// paths, which the host draws again without copying or flattening them. A step of the drag +/// replaces only the paths of the pieces near it. +#[test] +fn the_settled_outline_of_a_long_drag_is_the_same_shared_path_each_frame() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = ToolContext { precise_cursors: true, ..cx(&d, &s, &p) }; + let settled = |o: &[Overlay]| -> Vec> { + o.iter().filter_map(|o| if let Overlay::SharedPath { path, .. } = o { Some(std::sync::Arc::clone(path)) } else { None }).collect() + }; + let kept = |was: &[std::sync::Arc], now: &[std::sync::Arc]| { + now.iter().filter(|n| was.iter().any(|w| std::sync::Arc::ptr_eq(w, n))).count() + }; + for (id, angle, roundness, pressure) in + [("blobBrush", 0.0, 100.0, false), ("eraser", -60.0, 30.0, false), ("blobBrush", 0.0, 100.0, true), ("eraser", 45.0, 50.0, true)] + { + let mut t = GestureTool::new(id); + t.set_option("size", &json!(10)); + t.set_option("angle", &json!(angle)); + t.set_option("roundness", &json!(roundness)); + if pressure { + t.set_option("sizeMode", &json!("pressure")); + t.set_option("sizeVariation", &json!(4)); + } + let n = if pressure { 3000 } else { 6000 }; + long_area_drag(&mut t, &cx, n, |i| if pressure { 0.3 + 0.6 * ((i % 50) as f32 / 50.0) } else { 1.0 }); + drain_area(&t, &cx); + let first = settled(&t.overlays(&cx)); + assert!(first.len() > 1, "{id}: {} settled paths", first.len()); + for zoom in [1.0, 4.0, 1.0] { + let again = settled(&t.overlays(&ToolContext { zoom, ..cx })); + assert_eq!(again.len(), first.len(), "{id}"); + assert_eq!(kept(&first, &again), first.len(), "{id}: at zoom {zoom}, every settled path is the one shown before"); + } + // One step more along the drag: with Size set to Pressure each path holds 64 one-segment + // pieces, part of one turn of the drag, and the paths far from the step stay the ones shown + // before. (Without it a path holds 64 pieces of 32 segments, many turns round the circle.) + if !pressure { + continue; + } + let s = n as f64; + let r = 150.0 + 100.0 * (s * 0.0071).sin(); + let (x, y) = (300.0 + r * (s * 0.04).cos(), 300.0 + r * (s * 0.04).sin()); + t.pointer(&cx, &PointerEvent { pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, x, y) }); + drain_area(&t, &cx); + let after = settled(&t.overlays(&cx)); + let replaced = after.len() - kept(&first, &after); + assert!((1..=8).contains(&replaced), "{id}: {replaced} of {} settled paths replaced", after.len()); + } +} + +/// The outline a Blob Brush or Eraser drag shows does not change what its release sends: the same +/// drag sends the same command whether or not its outline was drawn along the way. +#[test] +fn drawing_the_outline_of_a_drag_does_not_change_what_its_release_sends() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = ToolContext { precise_cursors: true, ..cx(&d, &s, &p) }; + for (id, angle, roundness, pressure) in + [("blobBrush", 0.0, 100.0, false), ("blobBrush", 30.0, 40.0, true), ("eraser", -60.0, 30.0, false), ("eraser", 45.0, 50.0, true)] + { + let release = |drawn: bool| { + let mut t = GestureTool::new(id); + t.set_option("size", &json!(10)); + t.set_option("angle", &json!(angle)); + t.set_option("roundness", &json!(roundness)); + if pressure { + t.set_option("sizeMode", &json!("pressure")); + t.set_option("sizeVariation", &json!(4)); + } + for i in 0..800 { + let s = i as f64; + let r = 150.0 + 100.0 * (s * 0.0071).sin(); + let (x, y) = (300.0 + r * (s * 0.04).cos(), 300.0 + r * (s * 0.04).sin()); + let kind = if i == 0 { PointerKind::Down } else { PointerKind::Drag }; + let pressure = if pressure { 0.3 + 0.6 * ((i % 50) as f32 / 50.0) } else { 1.0 }; + t.pointer(&cx, &PointerEvent { pressure, ..PointerEvent::new(kind, x, y) }); + if drawn { + t.overlays(&cx); + } + } + if drawn { + drain_area(&t, &cx); + } + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 300.0)) + }; + let (drawn, not_drawn) = (release(true), release(false)); + assert!(matches!(drawn.first(), Some(Action::Exec(..))), "{id}: {drawn:?}"); + assert_eq!(drawn, not_drawn, "{id}"); + } +} + /// Every flattened point of the paths `o` (the outline shown). fn shown_points(o: &[Overlay]) -> Vec<(Point, Point)> { let mut lines = vec![]; for o in o { - let Overlay::Path { path, .. } = o else { continue }; + let path = match o { + Overlay::Path { path, .. } => path, + Overlay::SharedPath { path, .. } => &**path, + _ => continue, + }; let mut pen = Point::ZERO; let mut start = Point::ZERO; vectorcraft_geom::kurbo::flatten(path.elements().iter().copied(), 0.01, |el| match el { @@ -2541,7 +2641,11 @@ fn a_long_jump_in_a_blob_brush_drag_is_outlined_a_bounded_amount_at_a_time() { let o = t.overlays(&cx); let b = o .iter() - .filter_map(|o| if let Overlay::Path { path, .. } = o { Some(vectorcraft_geom::Shape::bounding_box(path)) } else { None }) + .filter_map(|o| match o { + Overlay::Path { path, .. } => Some(vectorcraft_geom::Shape::bounding_box(path)), + Overlay::SharedPath { path, .. } => Some(vectorcraft_geom::Shape::bounding_box(&**path)), + _ => None, + }) .reduce(|a, b| a.union(b)); let Some(b) = b else { panic!("{o:?}") }; assert!((b.x0 + 0.5).abs() < 0.01 && (b.x1 - 20_000.5).abs() < 0.01, "{b:?}"); diff --git a/crates/tools/src/input.rs b/crates/tools/src/input.rs index 9b53d2404c..5362147b97 100644 --- a/crates/tools/src/input.rs +++ b/crates/tools/src/input.rs @@ -29,6 +29,9 @@ pub enum PointerKind { /// Moved with no button (hover). Move, DoubleClick, + /// The third click of a triple-click, after its press and release (the second click's + /// release sends [`PointerKind::DoubleClick`]). + TripleClick, } #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 93f71431c1..9edf18e84d 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -71,6 +71,10 @@ pub enum Overlay { SelectionMarquee { rect: Rect, enclosed: bool, pointer: Point }, /// A path preview (e.g. pen rubber band) in a colour (RGB). Path { path: BezPath, color: [u8; 3], width: f32, dashed: bool }, + /// A solid path preview that later calls return again, as the same shared `path`, for as long + /// as it does not change (the settled outline of a long Blob Brush or Eraser drag). The host + /// can keep what it built to draw it while `path` is the same allocation. + SharedPath { path: std::sync::Arc, color: [u8; 3], width: f32 }, /// A line segment (smart guide, handle line). Line { a: Point, b: Point, color: [u8; 3], dashed: bool }, /// An anchor square: filled = selected. diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index a0ab57ae87..24a21d08ca 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -449,7 +449,7 @@ impl Tool for PenTool { } vec![Action::Commit] } - PointerKind::DoubleClick => vec![], + PointerKind::DoubleClick | PointerKind::TripleClick => vec![], } } fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec { diff --git a/crates/tools/src/printtiling.rs b/crates/tools/src/printtiling.rs index b4e0e06d0c..a28382daf8 100644 --- a/crates/tools/src/printtiling.rs +++ b/crates/tools/src/printtiling.rs @@ -68,7 +68,7 @@ impl Tool for PrintTilingTool { self.drag = None; vec![Action::Exec("print.tiling.set".into(), json!({ "reset": true }))] } - PointerKind::Move => vec![], + PointerKind::Move | PointerKind::TripleClick => vec![], } } diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index dd561bc616..e18754f0ca 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -113,6 +113,7 @@ impl Tool for TypeTool { } vec![] } + PointerKind::TripleClick => vec![], PointerKind::Move => { // The type being edited is no target: its guides would only point at itself. self.snap.hover(cx, ev.pos, self.editing.as_slice(), None); diff --git a/crates/tools/src/touchtype.rs b/crates/tools/src/touchtype.rs index 018a65aefc..d195f276e4 100644 --- a/crates/tools/src/touchtype.rs +++ b/crates/tools/src/touchtype.rs @@ -387,7 +387,7 @@ impl Tool for TouchTypeTool { Some(d) if d.began => vec![Action::Commit], _ => vec![], }, - PointerKind::DoubleClick | PointerKind::Move => vec![], + PointerKind::DoubleClick | PointerKind::TripleClick | PointerKind::Move => vec![], } } diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 9a61c98591..5a3565f3a9 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -582,7 +582,7 @@ impl Tool for GradientTool { let at = a.stop_point(cx, i).unwrap_or(ev.pos); vec![Self::select_stop(i), Action::Dialog("gradientStop".into(), json!({ "index": i, "x": at.x, "y": at.y }))] } - PointerKind::Move => vec![], + PointerKind::Move | PointerKind::TripleClick => vec![], } } diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index 263c0ea5e4..1cbf1cac53 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -8,10 +8,12 @@ //! while hovering, so transforms pivot exactly on a corner. A drag starts from the point it grabbed //! snapped the same way, and the pointer snaps as a drawn point does (Smart Guides: the other //! objects' anchors, centres and edges; or the grid, pixels, Snap to Point), so a corner dragged -//! onto another object's anchor scales exactly onto it. +//! onto another object's anchor scales exactly onto it. Scale stretches along the bounding box's +//! own width and height, turned with rotated objects until Reset Bounding Box; Reflect's default +//! axis is the box's vertical one, and Shift constrains a drawn axis to 45° steps from the box. use serde_json::{Value, json}; -use vectorcraft_doc::NodeId; +use vectorcraft_doc::{NodeId, OrientedBox}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::target_overlays; @@ -68,9 +70,28 @@ struct Drag { /// Alt was down at the press: a click opens the dialog, a drag transforms a copy. alt: bool, bounds: Rect, + /// The angle of the selection's bounding box: Scale and Reflect act along its axes + /// ([`TransformTool::matrix_in`]). + angle: f64, + /// Anchors are selected with Direct Selection: they transform alone along the page's axes, + /// and a copy or a pattern-only drag transforms the whole objects along the box + /// ([`Drag::angle_for`]). + anchors: bool, + /// The snapped pointer and Shift at the last drag event, to work out the transform again + /// when the Alt state at the release changes what it transforms. + pointer: Point, + shift: bool, last: Affine, } +impl Drag { + /// The angle Scale and Reflect act along, for a drag that copies (`copy`) or not, and that + /// transforms only pattern tiles (`patterns_only`, the backquote key) or not. + fn angle_for(&self, copy: bool, patterns_only: bool) -> f64 { + if self.anchors && !copy && !patterns_only { 0.0 } else { self.angle } + } +} + pub struct TransformTool { kind: TransformKind, /// Custom reference point and the selection it was set for. @@ -125,6 +146,16 @@ impl TransformTool { /// Transform for dragging from `start` to `p` about `o`; returns the matrix and a readout. pub fn matrix_for(&self, o: Point, start: Point, p: Point, shift: bool, bounds: Rect) -> (Affine, String) { + self.matrix_in(0.0, o, start, p, shift, bounds) + } + + /// [`TransformTool::matrix_for`] with the bounding box turned by `angle` (counter-clockwise + /// degrees): Scale stretches along the box's own width and height, and Reflect's default axis + /// and Shift's 45° steps start from the box's axes. A Reflect axis drawn by the drag is the + /// line the pointer draws on the page. + pub fn matrix_in(&self, angle: f64, o: Point, start: Point, p: Point, shift: bool, bounds: Rect) -> (Affine, String) { + let frame = OrientedBox { angle, rect: Rect::ZERO }; + let local = |v: Vec2| frame.to_doc().inverse() * v.to_point(); match self.kind { TransformKind::Rotate => { let (a, deg) = rotate_for_drag(o, start, p, shift); @@ -132,15 +163,17 @@ impl TransformTool { } TransformKind::Reflect => { let mut v = p - o; - if shift { - v = vectorcraft_geom::constrain_angle(v, 45.0); + if v.hypot() < 1e-9 { + v = (frame.to_doc() * Point::new(0.0, 1.0)).to_vec2(); + } else if shift { + v = (frame.to_doc() * vectorcraft_geom::constrain_angle(local(v).to_vec2(), 45.0).to_point()).to_vec2(); } - let theta = if v.hypot() < 1e-9 { std::f64::consts::FRAC_PI_2 } else { v.y.atan2(v.x) }; + let theta = v.y.atan2(v.x); (reflect_matrix(o, theta), format!("{:.1}°", vectorcraft_geom::normalize_deg(-theta.to_degrees()))) } TransformKind::Scale => { - let d0 = start - o; - let d1 = p - o; + let d0 = local(start - o).to_vec2(); + let d1 = local(p - o).to_vec2(); let ratio = |a: f64, b: f64| if a.abs() > 1e-6 { b / a } else { 1.0 }; let (mut sx, mut sy) = (ratio(d0.x, d1.x), ratio(d0.y, d1.y)); if shift { @@ -151,7 +184,7 @@ impl TransformTool { } let clamp = |s: f64| if s.abs() < 1e-4 { 1e-4f64.copysign(s) } else { s }; let (sx, sy) = (clamp(sx), clamp(sy)); - (about(o, Affine::scale_non_uniform(sx, sy)), format!("W: {:.1}%\nH: {:.1}%", sx * 100.0, sy * 100.0)) + (about(o, frame.conjugate(Affine::scale_non_uniform(sx, sy))), format!("W: {:.1}%\nH: {:.1}%", sx * 100.0, sy * 100.0)) } TransformKind::Shear => { // The shear axis follows the dominant drag direction. Shift keeps the drag on that @@ -227,7 +260,21 @@ impl Tool for TransformTool { // Alt: decided on release. A click sets the point and opens the dialog; a drag // transforms a copy about the reference point as it was. let origin = self.reference_point(cx).unwrap_or(bounds.center()); - self.drag = Some(Drag { start: p, pick, origin, began: false, alt: ev.mods.alt, bounds, last: Affine::IDENTITY }); + let anchors = vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure()).is_some(); + let angle = selection_box(cx).map_or(0.0, |b| b.angle); + self.drag = Some(Drag { + start: p, + pick, + origin, + began: false, + alt: ev.mods.alt, + bounds, + angle, + anchors, + pointer: p, + shift: false, + last: Affine::IDENTITY, + }); vec![] } PointerKind::Drag => { @@ -247,8 +294,8 @@ impl Tool for TransformTool { } let (p, guides) = self.snap.as_ref().map_or((p, vec![]), |s| s.snap(cx, p)); self.hover = guides; - let (m, text) = self.matrix_for(d.origin, d.pick, p, ev.mods.shift, d.bounds); - d.last = m; + let (m, text) = self.matrix_in(d.angle_for(ev.mods.alt, ev.backquote), d.origin, d.pick, p, ev.mods.shift, d.bounds); + (d.last, d.pointer, d.shift) = (m, p, ev.mods.shift); self.drag = Some(d); self.measure = Some((p, text)); out.push(preview(m, ev.mods.alt, ev.backquote)); @@ -257,8 +304,15 @@ impl Tool for TransformTool { 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, ev.backquote), Action::Commit] + // The Alt and backquote states at release decide whether the result is a copy + // or moves only pattern tiles, and with anchors selected, whether it acts + // along the box or the page. + let m = if d.anchors { + self.matrix_in(d.angle_for(ev.mods.alt, ev.backquote), d.origin, d.pick, d.pointer, d.shift, d.bounds).0 + } else { + d.last + }; + vec![preview(m, 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())); @@ -272,6 +326,7 @@ impl Tool for TransformTool { None => vec![], } } + PointerKind::TripleClick => vec![], PointerKind::Move => { if self.drag.is_none() { self.hover = if cx.selection.is_empty() { vec![] } else { snap_pick(cx, p).1 }; @@ -578,6 +633,70 @@ mod tests { assert!((m * Point::new(100.0, 120.0)).distance(Point::new(200.0, 120.0)) < 1e-9); } + /// With the selection turned by 30°, a Scale drag stretches along the box's own sides, and + /// Reflect's default axis and Shift steps start from the box's axes while a drawn axis stays + /// the line drawn on the page. + #[test] + fn scale_and_reflect_drags_follow_a_turned_bounding_box() { + let (mut d, id) = doc_with_rect(); + let c = Point::new(150.0, 150.0); + d.node_mut(id).unwrap().transform(about(c, Affine::rotate(-30f64.to_radians())), false); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let mut cx = cx(&d, &s, &p); + (cx.smart_guides, cx.snap_to_point) = (false, false); + let frame = OrientedBox { angle: 30.0, rect: Rect::ZERO }; + let along = |x: f64, y: f64| c + (frame.to_doc() * Point::new(x, y)).to_vec2(); + // The right side's middle dragged out to twice its distance: the width doubles along the + // box, and the height stays. + let mut t = TransformTool::new(TransformKind::Scale); + let (from, to) = (along(50.0, 0.0), along(100.0, 0.0)); + t.pointer(&cx, &ev(PointerKind::Down, from.x, from.y)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, to.x, to.y)); + let m = matrix(&a[1]); + assert!((m * along(50.0, 50.0)).distance(along(100.0, 50.0)) < 1e-9, "{m:?}"); + assert!((m * along(0.0, 50.0)).distance(along(0.0, 50.0)) < 1e-9, "{m:?}"); + assert!(a.iter().any(|x| matches!(x, Action::Preview(..)))); + + let t = TransformTool::new(TransformKind::Reflect); + // No axis drawn: the box's vertical axis. + let (m, _) = t.matrix_in(30.0, c, c, c, false, Rect::ZERO); + assert!((m * along(40.0, 10.0)).distance(along(-40.0, 10.0)) < 1e-9, "{m:?}"); + // Shift: 39° from the box's horizontal axis steps to 45° from it. + let (m, text) = t.matrix_in(30.0, c, c, along(10.0, 8.0), true, Rect::ZERO); + assert!((m * along(10.0, 0.0)).distance(along(0.0, 10.0)) < 1e-9, "{m:?}"); + assert_eq!(text, "-15.0°"); + // A drawn axis is the line on the page. + let (m, _) = t.matrix_in(30.0, c, c, c + Vec2::new(0.0, -50.0), false, Rect::ZERO); + assert!((m * Point::new(100.0, 120.0)).distance(Point::new(200.0, 120.0)) < 1e-9, "{m:?}"); + + // Anchors selected with Direct Selection scale alone along the page's axes; Alt at the + // release copies the whole object, which scales along its box. + let mut s = s.clone(); + s.anchors.insert(id, [(0, 0), (0, 1)].into()); + let mut cx = crate::testutil::cx(&d, &s, &p); + (cx.smart_guides, cx.snap_to_point) = (false, false); + let page_axes = |m: Affine| m.as_coeffs()[1].abs() < 1e-9 && m.as_coeffs()[2].abs() < 1e-9; + let (from, to) = (along(50.0, 0.0), along(100.0, 30.0)); + for copy in [false, true] { + let mut t = TransformTool::new(TransformKind::Scale); + t.pointer(&cx, &ev(PointerKind::Down, from.x, from.y)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, to.x, to.y)); + assert!(page_axes(matrix(&a[1])), "{a:?}"); + let up = t.pointer(&cx, &ev(PointerKind::Up, to.x, to.y).with_mods(if copy { alt() } else { Mods::default() })); + assert_eq!(page_axes(matrix(&up[0])), !copy, "copy {copy}: {up:?}"); + } + // With the backquote key the drag moves only the whole objects' pattern tiles, which + // scale along the box, both while dragging and at the release. + let mut t = TransformTool::new(TransformKind::Scale); + t.pointer(&cx, &ev(PointerKind::Down, from.x, from.y)); + let a = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, to.x, to.y) }); + assert!(!page_axes(matrix(&a[1])), "{a:?}"); + let up = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Up, to.x, to.y) }); + assert!(!page_axes(matrix(&up[0])), "{up:?}"); + } + #[test] fn shear_horizontal_keeps_origin_row() { let t = TransformTool::new(TransformKind::Shear); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 412dbcb4ef..658068f79b 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -85,6 +85,7 @@ impl VectorcraftApp { hidden_chars: Default::default(), spelling: Default::default(), selection_marks: Default::default(), + shared_paths: Default::default(), selection_box: None, selection_bounds: None, content_bounds: None, diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index 286b4d4d02..ae2ddc5dae 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -51,6 +51,9 @@ pub struct CanvasCache { /// The selected objects' outlines, anchors and handles as drawn in the last frame. A frame that /// does not draw them drops them ([`canvas::show`]). pub selection_marks: canvas::SelectionMarks, + /// The meshes the tools' shared paths (a long Blob Brush or Eraser drag's settled outline) were + /// drawn with in the last frame. A frame that does not draw them drops them ([`canvas::show`]). + pub shared_paths: canvas::SharedPaths, /// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds and /// Measure Type by Glyph Bounds). pub selection_box: Option<((u64, u64, vectorcraft_effects::preview::Measure), Option)>, @@ -164,6 +167,17 @@ pub struct CacheKey { /// The display profile the frame is converted into ([`crate::display_profile::DisplayProfile::key`]), /// 0 for none. pub display: u64, + /// The paper under the art that translucent art is blended with before the conversion + /// ([`crate::display_profile::convert_over`]). None without a display profile, and in Outline. + pub backdrop: Option>, +} + +impl CacheKey { + /// [`Self::backdrop`] with the zoom its grid's cells are measured at, as + /// [`crate::display_profile::convert_over`] takes it. + pub(crate) fn over(&self) -> Option<(&crate::display_profile::Backdrop, f64)> { + self.backdrop.as_deref().map(|b| (b, self.zoom)) + } } #[derive(Clone, Copy, Debug, Default)] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5c30f96246..7095e6a9ed 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -60,6 +60,7 @@ use input::handle_input; use outline::KEY_OUTLINE; #[cfg(test)] use outline::{OVERLAY_MAX_ANCHORS, c32, node_outline, walk_drawn}; +pub use overlays::SharedPaths; #[cfg(test)] use overlays::selected_anchor; use overlays::{HandleLook, blend_widgets_overlay, draw_inside_overlay, draw_overlays, hover_highlight, ime_geometry, selection_overlay}; @@ -244,6 +245,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let hidden_chars = std::mem::take(&mut app.canvas.hidden_chars); // The selection's kept marks. Likewise, a frame that does not draw them drops them. let selection_marks = std::mem::take(&mut app.canvas.selection_marks); + // The meshes of the tools' shared paths. Likewise, a frame that does not draw the tools' + // overlays drops them. + let mut shared_paths = std::mem::take(&mut app.canvas.shared_paths); // A release ends the Image Trace panel's eye-button hold, also when the panel is no longer // drawn, so a later press elsewhere does not show the source image. if app.ui.trace_hold_source.is_some() && !ui.input(|i| i.pointer.any_down()) { @@ -415,7 +419,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { original_path_overlay(app, &painter, &xf); let overlays = selection_visuals(app, &painter, &xf, (hidden_chars, selection_marks, hover_still), revolve_edit, view_info); - draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs)); + draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs), &mut shared_paths); + app.canvas.shared_paths = shared_paths; if !revolve_edit { shaper_widget_overlay(app, &painter, &xf); crate::panels::three_d::paint_graphic_handles(app, &painter, &xf); @@ -631,6 +636,7 @@ fn cache_key( trace_source: app.ui.trace_hold_source.clone(), // Preferences › Performance › Use Monitor Profile for Canvas. display: app.display_profile.key(app.session.prefs.monitor_profile_display), + backdrop: backdrop(app, st, app.outline_view()), } } @@ -895,9 +901,10 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { } if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) { let renderer = &mut app.canvas.renderer; - let mut part = - effects.view(|| renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts)); - crate::display_profile::convert(display.as_deref(), &mut part.pixels); + let part_view = Affine::translate((-f64::from(x0), -f64::from(y0))) * view; + let mut part = effects.view(|| renderer.render(doc, x1 - x0, y1 - y0, part_view, &opts)); + let width = part.width; + crate::display_profile::convert_over(display.as_deref(), &mut part.pixels, width, part_view, key.over()); let color = color_image(&part); let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; tex.set_partial([x0 as usize, y0 as usize], color, options); @@ -935,7 +942,8 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { let renderer = &mut app.canvas.renderer; let mut img = effects.view(|| renderer.render(doc, w, h, view, &opts)); effects.first_view_done(); - crate::display_profile::convert(display.as_deref(), &mut img.pixels); + let width = img.width; + crate::display_profile::convert_over(display.as_deref(), &mut img.pixels, width, view, key.over()); upload(app, ui.ctx(), img, pixel.is_some()); app.canvas.key = Some(key.clone()); app.canvas.shown = Some((doc.clone(), opts.clone())); @@ -990,13 +998,11 @@ fn paint_paper( let on_display = |c: Color32| crate::display_profile::color32(display.as_deref(), c); if !shows_artboards(app) { // View › Hide Artboards: no edges and no pasteboard, the whole canvas paper (#4038). - let setup = &doc.setup; - painter.rect_filled(rect, 0.0, on_display(if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE })); + painter.rect_filled(rect, 0.0, on_display(paper_color(&doc.setup))); } else { let setup = &doc.setup; - let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent) - .then(|| checker_texture(ui.ctx(), setup, display.as_deref())); - let paper = if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE }; + let grid = shows_transparency_grid(st).then(|| checker_texture(ui.ctx(), setup, display.as_deref())); + let paper = paper_color(setup); for ab in &doc.artboards { let q = xf.quad(ab.rect); // Hard 2 pt drop shadow, right and bottom (measured: #4d4d4d then #565656 on #606060). @@ -1006,14 +1012,52 @@ fn paint_paper( match &grid { Some((tex, cell)) => checker(painter, &q, ab.rect, xf.zoom, tex.id(), *cell), None => { - let fill = ab.color.map_or(paper, |c| crate::panels::c32(&c) * paper); - painter.add(Shape::convex_polygon(q, on_display(fill), Stroke::NONE)); + painter.add(Shape::convex_polygon(q, on_display(artboard_paper(ab, paper)), Stroke::NONE)); } } } } } +/// The color of the paper: Simulate Colored Paper's, else white. +fn paper_color(setup: &vectorcraft_doc::DocSetup) -> Color32 { + if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE } +} + +/// The color of artboard `ab`'s paper, the paper `paper`: an artboard with a background color +/// multiplies it with the paper, each sRGB channel artboard × paper / 255, rounded. +fn artboard_paper(ab: &vectorcraft_doc::Artboard, paper: Color32) -> Color32 { + ab.color.map_or(paper, |c| crate::panels::c32(&c) * paper) +} + +/// The transparency grid covers the artboards of document `st`: it is shown, and the Background +/// Contents are transparent. +fn shows_transparency_grid(st: &vectorcraft_engine::DocState) -> bool { + st.transparency_grid && st.doc.setup.background == vectorcraft_doc::Background::Transparent +} + +/// The paper [`paint_paper`] draws for document `st`, which translucent art is blended with before +/// the display profile's conversion. None without a display profile, and in Outline (`outline`), +/// whose white canvas is the interface's. +fn backdrop(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, outline: bool) -> Option> { + if outline || app.display_profile.transform(app.session.prefs.monitor_profile_display).is_none() { + return None; + } + let rgb = |c: Color32| [c.r(), c.g(), c.b()]; + let setup = &st.doc.setup; + let paper = paper_color(setup); + let b = if shows_artboards(app) { + crate::display_profile::Backdrop { + everywhere: None, + artboards: st.doc.artboards.iter().map(|ab| (ab.rect, rgb(artboard_paper(ab, paper)))).collect(), + grid: shows_transparency_grid(st).then(|| (setup.grid_colors.map(|c| rgb(crate::panels::c32(&c))), setup.grid_size.cell())), + } + } else { + crate::display_profile::Backdrop { everywhere: Some(rgb(paper)), artboards: vec![], grid: None } + }; + Some(std::sync::Arc::new(b)) +} + /// The last rendered frame, placed through the current view while a new one is on its way. fn reproject_frame(app: &VectorcraftApp, painter: &egui::Painter, xf: &Xf, rect: egui::Rect, (key_doc, rotation): (usize, f64)) { if let (Some(tex), Some(k)) = (&app.canvas.texture, &app.canvas.key) diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 57dbef9ff8..88bb262844 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -179,6 +179,12 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo { double_click(app, ui, &f, p); } + if resp.triple_clicked() + && !f.modal + && let Some(p) = at + { + triple_click(app, ui, &f, p); + } if drag.is_some() || pointer.is_moving() || mods_changed { ui.ctx().request_repaint(); } @@ -591,6 +597,23 @@ fn double_click(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) { } } +/// A triple-click at `p`: the tool gets it, unless it is on an artboard's name. +fn triple_click(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) { + let Pointing { m, space, xf, view, .. } = *f; + if artboard_name_at(app, ui, &xf, p).is_some() { + return; + } + let ev = PointerEvent { + kind: PointerKind::TripleClick, + pos: xf.to_doc(p), + mods: mods(m, space), + backquote: ui.input(|i| i.key_down(egui::Key::Backtick)), + pressure: 1.0, + pen: None, + }; + dispatch(app, &ev, view); +} + /// Where `button` went up in this frame, if it did. fn release_pos(ui: &Ui, button: egui::PointerButton) -> Option { ui.input(|i| { diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 119c811d0b..6e222cc405 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -1,8 +1,14 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use egui::Mesh; +use egui::epaint::{TessellationOptions, Tessellator}; + use super::artboards::names_an_artboard; use super::input::marquee_through; use super::outline::{KEY_OUTLINE, c32, node_outline, selection_hit_at, stroke_path, to_screen_path, too_many_anchors}; use super::selection_marks::{MarkOptions, SelectionMarks}; -use super::{Color32, CornerRadius, NodeKind, Overlay, Pos2, Shape, Stroke, StrokeKind, Tokens, VectorcraftApp, Xf, vec2}; +use super::{BezPath, Color32, CornerRadius, NodeKind, Overlay, Pos2, Shape, Stroke, StrokeKind, Tokens, VectorcraftApp, Xf, vec2}; pub(super) fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { let Some(h) = app.hover_doc else { return }; @@ -351,7 +357,75 @@ pub(super) fn ime_geometry(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &X app.ime_caret_rect = Some((r, r)); } -pub(super) fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, look: HandleLook) { +/// The meshes the tools' shared paths ([`Overlay::SharedPath`]) were drawn with in the last frame +/// ([`crate::CanvasCache::shared_paths`]). Each is kept with its path, by the path's allocation, and +/// with the view, clip and stroke it was built for: a path that comes back unchanged at the same view +/// is drawn with the same mesh, without flattening or tessellating it again. A frame keeps only the +/// meshes it draws; a frame that draws no overlays drops them all ([`super::show`]). +#[derive(Default)] +pub struct SharedPaths { + pub(super) meshes: HashMap, +} + +pub(super) struct SharedMesh { + /// Held so that the allocation, the key of the map, is not reused while the mesh is kept. + path: Arc, + key: SharedKey, + pub(super) mesh: Arc, +} + +/// What a shared path's mesh depends on besides the path. +#[derive(Clone, Copy, PartialEq)] +struct SharedKey { + /// The view's mapping onto the screen ([`Xf::affine`]). + view: [f64; 6], + /// The painter's clip: the lines of the path outside it have no triangles in the mesh. + clip: egui::Rect, + stroke: Stroke, + pixels_per_point: f32, + tessellation: TessellationOptions, +} + +impl SharedPaths { + /// The mesh that draws `path` with `s` at the view `xf` with the painter `p`: the one kept in + /// this frame or in `last` (the last frame's) when it was built for the same, else a new one. + /// It is kept for the next frame. + fn mesh(&mut self, last: &mut HashMap, path: &Arc, xf: &Xf, s: Stroke, p: &egui::Painter) -> Arc { + let ctx = p.ctx(); + let key = SharedKey { + view: xf.affine().as_coeffs(), + clip: p.clip_rect(), + stroke: s, + pixels_per_point: ctx.pixels_per_point(), + tessellation: ctx.tessellation_options(|o| *o), + }; + let at = Arc::as_ptr(path) as usize; + let fits = |m: &SharedMesh| m.key == key && Arc::ptr_eq(&m.path, path); + if let Some(kept) = self.meshes.get(&at).filter(|m| fits(m)) { + return Arc::clone(&kept.mesh); + } + let kept = match last.remove(&at).filter(|m| fits(m)) { + Some(kept) => kept, + None => { + let font_image_size = ctx.fonts(|f| f.font_image_size()); + let mut tessellator = Tessellator::new(key.pixels_per_point, key.tessellation, font_image_size, vec![]); + tessellator.set_clip_rect(key.clip); + let mut mesh = Mesh::default(); + for line in to_screen_path(path, xf) { + tessellator.tessellate_shape(Shape::line(line, s), &mut mesh); + } + SharedMesh { path: Arc::clone(path), key, mesh: Arc::new(mesh) } + } + }; + let mesh = Arc::clone(&kept.mesh); + self.meshes.insert(at, kept); + mesh + } +} + +pub(super) fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, look: HandleLook, shared: &mut SharedPaths) { + // The meshes kept from the last frame; those this frame does not draw are dropped. + let mut last = std::mem::take(&mut shared.meshes); for o in overlays { match o { // Where the tools select: the document rectangle, turned with the view. @@ -389,6 +463,12 @@ pub(super) fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: stroke_path(p, path, xf, s); } } + Overlay::SharedPath { path, color, width } => { + let mesh = shared.mesh(&mut last, path, xf, Stroke::new(*width, c32(*color)), p); + if !mesh.is_empty() { + p.add(Shape::mesh(mesh)); + } + } Overlay::Line { a, b, color, dashed } => { let s = Stroke::new(1.0, c32(*color)); let (a, b) = (xf.to_screen(*a), xf.to_screen(*b)); diff --git a/crates/ui-egui/src/canvas/panes.rs b/crates/ui-egui/src/canvas/panes.rs index 0a26f26843..50fd2c9583 100644 --- a/crates/ui-egui/src/canvas/panes.rs +++ b/crates/ui-egui/src/canvas/panes.rs @@ -11,7 +11,7 @@ use super::grid::LineLook; use super::outline::{c32, node_outline, stroke_path, too_many_anchors}; use super::view::fit_zoom; use super::{ - Affine, CacheKey, Point, Rect, RulerScale, Sense, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, Xf, artboard_edges, cache_key, + Affine, CacheKey, Point, Rect, RulerScale, Sense, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, Xf, artboard_edges, backdrop, cache_key, dim_on_canvas, frame_options, frame_view, grid, guides_of, json, paint_paper, pixel_region, placed_key, plugin_key, pos2, rulers, shows_artboards, textured_quad, vec2, view_rects, }; @@ -29,7 +29,7 @@ type Slot = (u64, u32); pub struct Panes { /// Renders off the UI thread (None: on it, as on wasm). worker: Option>, - worker_started: bool, + pub(crate) worker_started: bool, /// Renders on the UI thread when there is no worker. renderer: Option, /// By slot: the render shown and what it shows. @@ -112,11 +112,11 @@ impl Panes { let cms = vectorcraft_engine::cmd::colormgmt::doc_cms(&doc); let mut queued = false; if let Some(worker) = &self.worker { - let (job_doc, job_opts, job_display, job_cms) = (doc.clone(), opts.clone(), display.clone(), cms.clone()); + let (job_doc, job_opts, job_display, job_cms, job_key) = (doc.clone(), opts.clone(), display.clone(), cms.clone(), key.clone()); queued = worker.submit( slot, (slot, key.clone()), - Box::new(move |r| Some(render_pane(r, (&job_doc, w, h, view, &job_opts), job_display.as_deref(), job_cms))), + Box::new(move |r| Some(render_pane(r, (&job_doc, w, h, view, &job_opts), job_display.as_deref(), job_cms, job_key.over()))), ); if queued { self.pending.insert(slot, key.clone()); @@ -126,7 +126,7 @@ impl Panes { } if !queued { let r = self.renderer.get_or_insert_with(vectorcraft_render::Renderer::new); - let img = render_pane(r, (&doc, w, h, view, &opts), display.as_deref(), cms); + let img = render_pane(r, (&doc, w, h, view, &opts), display.as_deref(), cms, key.over()); self.upload(ctx, slot, key, &img); } } @@ -146,15 +146,18 @@ impl Panes { } /// Render `doc` (`w` × `h` pixels through `view`, with `opts`) through the color settings `cms`, -/// then convert it into the display profile `display`. +/// then convert it into the display profile `display`, blended with the backdrop `over` first +/// ([`crate::display_profile::convert_over`]). fn render_pane( r: &mut vectorcraft_render::Renderer, (doc, w, h, view, opts): (&vectorcraft_doc::Document, u32, u32, Affine, &vectorcraft_render::RenderOptions), display: Option<&vectorcraft_color::cms::display::DisplayTransform>, cms: Arc, + over: Option<(&crate::display_profile::Backdrop, f64)>, ) -> vectorcraft_render::Rendered { let mut img = vectorcraft_color::cms::with_active(cms, || r.render(doc, w, h, view, opts)); - crate::display_profile::convert(display, &mut img.pixels); + let width = img.width; + crate::display_profile::convert_over(display, &mut img.pixels, width, view, over); img } @@ -348,6 +351,7 @@ fn draw_pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect) -> boo // The current document's drag and Image Trace hold are not this document's, and this view has // its own Outline or Preview. (key.dragging, key.trace_source, key.outline) = (false, None, v.outline); + key.backdrop = backdrop(app, st, v.outline); let opts = frame_options(app, &key, st.shown_mask(), smooth_images); let (fw, fh, view) = frame_view(pixel, (pw, ph), &drawn, ppp); let doc = st.doc.clone(); diff --git a/crates/ui-egui/src/canvas/raster.rs b/crates/ui-egui/src/canvas/raster.rs index f57a15a795..2f9a4f981f 100644 --- a/crates/ui-egui/src/canvas/raster.rs +++ b/crates/ui-egui/src/canvas/raster.rs @@ -226,7 +226,8 @@ pub(crate) fn render_job(r: &mut vectorcraft_render::Renderer, kept: &mut Option *kept = keeps.then(|| KeptFrame { key: job.key.clone(), opts: job.opts.clone(), view: job.view, pixels: holds_pixels.then(|| img.pixels.clone()) }); // The kept frame stays sRGB, so the next frame's copied pixels are converted only once. - crate::display_profile::convert(job.display.as_deref(), &mut img.pixels); + let width = img.width; + crate::display_profile::convert_over(job.display.as_deref(), &mut img.pixels, width, job.view, job.key.over()); WorkerFrame { img, moved, reused } } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index a5ee4dfb0a..3e21381cb1 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -17,6 +17,7 @@ fn marquee_feedback_explains_the_rule_and_stays_on_canvas() { &[Overlay::SelectionMarquee { rect: Rect::new(20.0, y - 60.0, 300.0, y), enclosed, pointer: Point::new(315.0, y) }], &t, overlays::HandleLook::of(&prefs), + &mut overlays::SharedPaths::default(), ); } }; @@ -61,6 +62,89 @@ fn marquee_feedback_explains_the_rule_and_stays_on_canvas() { } } +/// A tool's shared path ([`Overlay::SharedPath`], the settled outline of a long Blob Brush or Eraser +/// drag) is drawn with the mesh kept from the last frame while the path and the view stay the same, +/// and that mesh is the one the plain path draws. Another path, even one with the same elements, or +/// a new view builds a new mesh, and a frame keeps only the meshes it draws. +#[test] +fn a_shared_tool_path_is_drawn_with_the_mesh_kept_while_it_and_the_view_stay() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, crate::theme::Brightness::MediumDark); + let prefs = vectorcraft_engine::Prefs::default(); + let mut shared = overlays::SharedPaths::default(); + let frame = |overlays: &[Overlay], zoom: f64, shared: &mut overlays::SharedPaths| { + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + let t = Tokens::get(ui.ctx()); + let xf = Xf { rect: ui.max_rect(), zoom, center: Point::new(200.0, 150.0), rot: 0.3 }; + overlays::draw_overlays(ui.painter(), &xf, overlays, &t, overlays::HandleLook::of(&prefs), shared); + }); + out.textures_delta.clear(); + out.shapes + }; + let meshes = |shapes: &[egui::epaint::ClippedShape]| -> Vec> { + shapes.iter().filter_map(|c| if let Shape::Mesh(m) = &c.shape { Some(std::sync::Arc::clone(m)) } else { None }).collect() + }; + let one = |shapes: Vec| { + let found = meshes(&shapes); + assert_eq!(found.len(), 1, "one mesh"); + std::sync::Arc::clone(&found[0]) + }; + let mut path = BezPath::new(); + path.move_to((60.0, 80.0)); + path.curve_to((120.0, 20.0), (220.0, 260.0), (330.0, 120.0)); + path.close_path(); + path.move_to((150.0, 150.0)); + path.line_to((250.0, 160.0)); + let a = std::sync::Arc::new(path.clone()); + let b = std::sync::Arc::new(path.clone()); + let shared_of = |p: &std::sync::Arc| Overlay::SharedPath { path: std::sync::Arc::clone(p), color: [0x80, 0x80, 0x80], width: 1.0 }; + let first = one(frame(&[shared_of(&a)], 1.0, &mut shared)); + assert!(!first.vertices.is_empty()); + let again = one(frame(&[shared_of(&a)], 1.0, &mut shared)); + assert!(std::sync::Arc::ptr_eq(&first, &again), "the same path at the same view is drawn with the kept mesh"); + // The plain path draws the same triangles. + let plain = frame(&[Overlay::Path { path: path.clone(), color: [0x80, 0x80, 0x80], width: 1.0, dashed: false }], 1.0, &mut shared); + assert!(shared.meshes.is_empty(), "a frame that draws no shared path keeps no mesh"); + let lines: Vec<_> = plain.into_iter().filter(|c| matches!(c.shape, Shape::Path(_))).collect(); + assert_eq!(lines.len(), 2, "a line for each subpath"); + let tessellated = ctx.tessellate(lines, ctx.pixels_per_point()); + let triangles: Vec<_> = tessellated + .into_iter() + .filter_map(|c| if let egui::epaint::Primitive::Mesh(m) = c.primitive { Some(m) } else { None }) + .flat_map(|m| m.vertices) + .map(|v| (v.pos, v.color)) + .collect(); + assert_eq!(triangles, first.vertices.iter().map(|v| (v.pos, v.color)).collect::>()); + // A dropped mesh, another allocation of the same path and a new view each build a new mesh. + let rebuilt = one(frame(&[shared_of(&a)], 1.0, &mut shared)); + assert!(!std::sync::Arc::ptr_eq(&first, &rebuilt) && *first == *rebuilt); + let other = one(frame(&[shared_of(&b)], 1.0, &mut shared)); + assert!(!std::sync::Arc::ptr_eq(&rebuilt, &other) && *rebuilt == *other); + let zoomed = one(frame(&[shared_of(&b)], 2.0, &mut shared)); + assert!(!std::sync::Arc::ptr_eq(&other, &zoomed) && *other != *zoomed); + // Two paths in one frame: each keeps its own mesh, and only those two are kept. + let both = meshes(&frame(&[shared_of(&a), shared_of(&b)], 2.0, &mut shared)); + let both_again = meshes(&frame(&[shared_of(&a), shared_of(&b)], 2.0, &mut shared)); + assert_eq!(both.len(), 2); + assert!(both.iter().zip(&both_again).all(|(x, y)| std::sync::Arc::ptr_eq(x, y))); + assert!(std::sync::Arc::ptr_eq(&both[1], &zoomed), "b at zoom 2 was kept from its frame"); + assert_eq!(shared.meshes.len(), 2); + // A path off the canvas gets no triangles and draws nothing, and the lines of one partly off + // it are left out of its mesh. + let mut far = path.clone(); + far.apply_affine(vectorcraft_geom::Affine::translate((5000.0, 5000.0))); + let far = std::sync::Arc::new(far); + assert!(meshes(&frame(&[shared_of(&far)], 1.0, &mut shared)).is_empty()); + assert!(shared.meshes.values().all(|m| m.mesh.is_empty()) && shared.meshes.len() == 1); + let mut half = path.clone(); + half.move_to((5000.0, 5000.0)); + half.line_to((5100.0, 5000.0)); + let half = one(frame(&[shared_of(&std::sync::Arc::new(half))], 1.0, &mut shared)); + assert_eq!(*half, *first, "the subpath off the canvas adds no triangles"); +} + #[test] fn subpixel_dock_layout_does_not_change_pointer_document_coordinates() { let mut observed = Vec::new(); @@ -2717,6 +2801,140 @@ fn the_canvas_is_drawn_through_the_display_profile_while_the_preference_is_on() assert_eq!(count(&mut app, red), Some(srgb)); } +/// With a display profile, translucent art and the anti-aliased edges of art over the paper are +/// blended with the paper in sRGB before the conversion: drawn over the converted paper, each such +/// pixel shows the converted blend. Opaque art, and art over the pasteboard, convert as before. The +/// part an edit renders again and the render worker's frame do the same, a change of the +/// transparency grid renders the art again, and Outline converts the art alone. +#[test] +fn translucent_art_over_the_paper_is_blended_before_the_display_profile() { + use crate::display_profile::{Found, Reading}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.canvas.worker_started = true; // every frame on this thread + let half = app.run("shape.rectangle", json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [half], "color": "#ff0000"})).unwrap(); + app.run("transparency.set", json!({"ids": [half], "opacity": 50})).unwrap(); + let disc = app.run("shape.ellipse", json!({"x": 20, "y": 20, "width": 60, "height": 60})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [disc], "color": "#2040c0"})).unwrap(); + // Left of the artboard, over the pasteboard. + let outside = app.run("shape.rectangle", json!({"x": -60, "y": 100, "width": 40, "height": 40})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [outside], "color": "#00ff00"})).unwrap(); + app.run("transparency.set", json!({"ids": [outside], "opacity": 50})).unwrap(); + app.run("select.none", json!({})).unwrap(); + let ctx = egui::Context::default(); + // The art's raster one frame uploads: where it goes ([x, y] for a part), its width and its pixels. + let upload = |app: &mut VectorcraftApp| -> Option<(Option<[usize; 2]>, usize, Vec)> { + let (_, mut delta) = frame_output(app, &ctx); + let art = app.canvas.texture.as_ref()?.id(); + let up = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { + let egui::ImageData::Color(img) = &d.image; + (d.pos, img.width(), img.pixels.clone()) + }); + delta.clear(); + up + }; + upload(&mut app).expect("the first frame"); + let v = app.view_mut().unwrap(); + (v.zoom, v.center) = (1.0, Point::new(120.0, 150.0)); + let (_, width, srgb) = upload(&mut app).expect("the view at 100%"); + let key = app.canvas.key.clone().unwrap(); + assert!(key.backdrop.is_none(), "no display profile: nothing to blend"); + let p3 = crate::display_profile::tests::p3(); + let t = vectorcraft_color::cms::display::DisplayTransform::from_icc(&p3).unwrap(); + app.display_profile.report(Reading { display: Some("DP-1".into()), found: Found::Profile { icc: p3, from: "test".into() } }); + let (_, _, shown) = upload(&mut app).expect("drawn again, converted"); + let key = app.canvas.key.clone().unwrap(); + assert!(key.backdrop.is_some()); + let white = t.apply8([255; 3]); + // What the canvas shows of pixel `c` over the converted paper `under`, as egui blends it. + let over = |c: egui::Color32, under: [u8; 3]| { + let k = 255 - u32::from(c.a()); + [0, 1, 2].map(|i| u32::from(c.to_array()[i]) + (u32::from(under[i]) * k + 127) / 255) + }; + let plain = |s: egui::Color32| { + let mut px = s.to_array(); + t.apply_premultiplied(&mut px); + egui::Color32::from_rgba_premultiplied(px[0], px[1], px[2], px[3]) + }; + let (view_w, view_h) = (key.w, key.h); + let (_, _, view) = frame_view( + None, + (view_w, view_h), + &View { zoom: key.zoom, center: Point::new(key.cx, key.cy), rotation: key.rot, ..View::default() }, + key.ppp, + ); + let to_doc = view.inverse(); + let (mut blended, mut beside) = (0, 0); + for (i, (s, c)) in srgb.iter().zip(&shown).enumerate() { + let p = to_doc * Point::new((i % width) as f64 + 0.5, (i / width) as f64 + 0.5); + if s.a() == 0 || s.a() == 255 { + assert_eq!(*c, plain(*s), "opaque and empty pixels convert as before, at {p:?}"); + } else if (1.0..399.0).contains(&p.x) && (1.0..299.0).contains(&p.y) { + let k = 255 - u32::from(s.a()); + let mix = [0, 1, 2].map(|j| (u32::from(s.to_array()[j]) + (255 * k + 127) / 255).min(255) as u8); + let want = t.apply8(mix); + let got = over(*c, white); + assert!((0..3).all(|j| got[j].abs_diff(u32::from(want[j])) <= 1), "at {p:?}: {got:?} shown, {want:?} wanted"); + blended += 1; + } else if p.x < -1.0 { + assert_eq!(*c, plain(*s), "over the pasteboard the art converts alone, at {p:?}"); + beside += 1; + } + } + assert!(blended > 1000 && beside > 500, "{blended} pixels over the paper, {beside} over the pasteboard"); + // The test tells the two apart: the fill converted alone, over the converted paper, shows another color. + let at = view * Point::new(200.0, 150.0); + let at = at.y as usize * width + at.x as usize; + let (s, c) = (srgb[at], shown[at]); + assert!((50..=205).contains(&s.a()), "the 50% fill at the center: {s:?}"); + let alone = over(plain(s), white); + assert!((0..3).any(|j| alone[j].abs_diff(over(c, white)[j]) > 2), "{alone:?} and {:?}", over(c, white)); + // The render worker's frame of the same key is the same. + let opts = app.canvas.shown.as_ref().unwrap().1.clone(); + let doc = app.session.active().unwrap().doc.clone(); + let display = app.display_profile.transform(true); + let job = |key: &CacheKey, doc: &std::sync::Arc| crate::render_worker::Job { + key: key.clone(), + doc: doc.clone(), + w: view_w, + h: view_h, + view, + opts: opts.clone(), + scope: vectorcraft_engine::file_access::ScopeHandle::capture(), + effects: Default::default(), + may_copy: false, + may_keep: false, + display: display.clone(), + }; + let worker = crate::canvas::render_job(&mut vectorcraft_render::Renderer::new(), &mut None, &job(&key, &doc)).img; + let near = |a: &[u8], b: egui::Color32| a.iter().zip(b.to_array()).all(|(x, y)| x.abs_diff(y) <= 1); + assert!(worker.pixels.as_chunks::<4>().0.iter().zip(&shown).all(|(a, b)| near(a, *b)), "the worker's frame matches"); + // An edit renders its part again, blended too. + app.run("paint.setFill", json!({"ids": [half], "color": "#0000ff"})).unwrap(); + let (pos, part_w, part) = upload(&mut app).expect("the edit shows"); + let [x0, y0] = pos.expect("only its part"); + let key = app.canvas.key.clone().unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let whole = crate::canvas::render_job(&mut vectorcraft_render::Renderer::new(), &mut None, &job(&key, &doc)).img; + let whole = whole.pixels.as_chunks::<4>().0; + for (i, c) in part.iter().enumerate() { + let at = (y0 + i / part_w) * view_w as usize + x0 + i % part_w; + assert!(near(&whole[at], *c), "the part matches the whole frame at {at}: {c:?}, {:?}", whole[at]); + } + assert!(part.iter().filter(|c| c.a() != 0 && c.a() != 255).count() > 1000, "the blue fill is in the part"); + // The transparency grid replaces the paper under the art: drawn again. + app.run("view.transparencyGrid", json!({"on": true})).unwrap(); + assert!(upload(&mut app).is_some_and(|(pos, ..)| pos.is_none()), "the grid renders the art again"); + assert!(app.canvas.key.as_ref().and_then(|k| k.backdrop.as_ref()).is_some_and(|b| b.grid.is_some())); + // Outline: the white canvas is the interface's, so the art converts alone. + app.run("view.outline", json!({})).unwrap(); + upload(&mut app).expect("Outline renders again"); + assert!(app.canvas.key.as_ref().is_some_and(|k| k.outline && k.backdrop.is_none())); +} + /// View › Hide Template (#2000): a template layer's art leaves the canvas, and Show Template /// draws it again, dimmed as before; each re-renders at once. #[test] @@ -5527,6 +5745,74 @@ fn a_pane_draws_its_own_views_outline() { } } +/// With a display profile, a split-view pane or floating window blends translucent art with the +/// paper before the conversion, as the canvas does, and in its own Outline converts the art alone. +#[test] +fn a_pane_blends_translucent_art_before_the_display_profile() { + use crate::display_profile::{Found, Reading}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let half = app.run("shape.rectangle", json!({"x": 50, "y": 25, "width": 100, "height": 50})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [half], "color": "#ff0000"})).unwrap(); + app.run("transparency.set", json!({"ids": [half], "opacity": 50})).unwrap(); + app.run("select.none", json!({})).unwrap(); + crate::doc_windows::new_window(&mut app).unwrap(); + app.canvas.panes.worker_started = true; // every pane render on this thread + let p3 = crate::display_profile::tests::p3(); + let t = vectorcraft_color::cms::display::DisplayTransform::from_icc(&p3).unwrap(); + app.display_profile.report(Reading { display: Some("DP-1".into()), found: Found::Profile { icc: p3, from: "test".into() } }); + let ctx = egui::Context::default(); + let other = (0, 1); + let slot = (app.session.documents()[0].uid, 1); + // The pane's raster one frame uploads, and the key it was rendered for. + let pane = |app: &mut VectorcraftApp| -> (Vec, CacheKey) { + let cell = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0)); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + panes::pane(app, ui, other, cell); + }); + let (key, tex) = app.canvas.panes.shown.get(&slot).expect("the pane's render"); + let id = tex.id(); + let pixels = out.textures_delta.set.iter().find(|e| e.0.eq(&id)).and_then(|e| e.1.last()).map(|d| { + let egui::ImageData::Color(img) = &d.image; + img.pixels.clone() + }); + let key = key.clone(); + out.textures_delta.clear(); + (pixels.expect("the pane uploads"), key) + }; + app.session.prefs.monitor_profile_display = false; + let (srgb, key) = pane(&mut app); + assert!(key.backdrop.is_none(), "no display profile: nothing to blend"); + app.session.prefs.monitor_profile_display = true; + let (shown, key) = pane(&mut app); + assert!(key.backdrop.is_some(), "the pane's Preview blends with its paper"); + assert_eq!(srgb.len(), shown.len()); + let plain = |s: egui::Color32| { + let mut px = s.to_array(); + t.apply_premultiplied(&mut px); + egui::Color32::from_rgba_premultiplied(px[0], px[1], px[2], px[3]) + }; + let mut blended = 0; + for (s, c) in srgb.iter().zip(&shown) { + if s.a() == 0 || s.a() == 255 { + assert_eq!(*c, plain(*s), "opaque and empty pixels convert as before"); + } else { + assert_eq!(c.a(), s.a(), "the alpha is kept"); + let p = plain(*s); + if (0..3).any(|j| c.to_array()[j].abs_diff(p.to_array()[j]) > 2) { + blended += 1; + } + } + } + assert!(blended > 100, "{blended} translucent pixels blended with the paper"); + // The pane's own Outline: the art converts alone, while the current view stays in Preview. + crate::doc_windows::view_mut(&mut app, other).unwrap().outline = true; + app.view_mut().unwrap().outline = false; + let (_, key) = pane(&mut app); + assert!(key.outline && key.backdrop.is_none(), "the pane's Outline has no paper to blend with"); +} + /// While the rulers' origin is dragged out of their corner, its crosshair draws over the canvas in /// the theme's dim text color in Preview, and in Outline in the dark gray that shows on the white /// canvas. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index acc642cc0d..a2c29fadc4 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, backquote?, space?: "doc"|"screen"}], mods?, backquote?}`: +//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick|tripleclick, x, y, backquote?, space?: "doc"|"screen"}], mods?, backquote?}`: //! drive the active tool through the same path as the mouse, after the synthetic input sent //! before it was applied //! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: synthetic keyboard input @@ -1895,7 +1895,7 @@ fn rotate_view_pointer( PointerKind::Up => { app.control_rotate_press = None; } - PointerKind::Move | PointerKind::DoubleClick => {} + PointerKind::Move | PointerKind::DoubleClick | PointerKind::TripleClick => {} } Ok(()) } @@ -1963,6 +1963,7 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con "up" => PointerKind::Up, "move" => PointerKind::Move, "doubleclick" | "dblclick" => PointerKind::DoubleClick, + "tripleclick" => PointerKind::TripleClick, other => return Err(format!("unknown pointer kind `{other}`")), }; PointerEvent::json_check(e)?; diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index e807df7b01..8915c48216 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -48,6 +48,8 @@ const OT_SWITCHES: &[(&str, &str)] = &[ ("ot_titl", "titl"), ("ot_ordn", "ordn"), ("ot_frac", "frac"), + ("ot_hkna", "hkna"), + ("ot_ital", "ital"), ]; /// The feature tags the Figure menu sets. const FIGURE_TAGS: [&str; 4] = ["tnum", "onum", "pnum", "lnum"]; @@ -56,8 +58,21 @@ const FIGURE_VALUES: [&str; 5] = ["default", "tabularLining", "proportionalLinin /// The values of the Position field, in the order of the OpenType panel's Position menu. const POSITION_VALUES: [&str; 5] = ["default", "superscript", "subscript", "numerator", "denominator"]; /// The fields of OpenType Features. -const OT_FIELDS: &[&str] = - &["ot_figure", "ot_position", "ot_liga", "ot_calt", "ot_dlig", "ot_swsh", "ot_salt", "ot_titl", "ot_ordn", "ot_frac", "ot_sets"]; +const OT_FIELDS: &[&str] = &[ + "ot_figure", + "ot_position", + "ot_liga", + "ot_calt", + "ot_dlig", + "ot_swsh", + "ot_salt", + "ot_titl", + "ot_ordn", + "ot_frac", + "ot_hkna", + "ot_ital", + "ot_sets", +]; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Character Style Options").into(), @@ -106,6 +121,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { "ot_titl", "ot_ordn", "ot_frac", + "ot_hkna", + "ot_ital", "ot_sets", "section", ] @@ -926,6 +943,8 @@ pub(crate) fn opentype_body(ui: &mut egui::Ui, d: &mut Dialog) { ("ot_titl", tl!("Titling Alternates")), ("ot_ordn", tl!("Ordinals")), ("ot_frac", tl!("Fractions")), + ("ot_hkna", tl!("H or V Style")), + ("ot_ital", tl!("Roman Italics")), ] { switch(ui, d, key, caption, true); } @@ -2042,6 +2061,23 @@ mod tests { assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["frac"])); } + /// H or V Style and Roman Italics are on, off or unset switches of OpenType Features: they show + /// the style's `hkna` and `ital` tags, a changed switch replaces its tag and keeps the others, an + /// unset one removes it, and a style without them keeps none. + #[test] + fn kana_style_and_roman_italics_are_opentype_switches() { + let mut d = dialog_for(json!({"features": ["hkna", "-ital", "dlig"]})); + assert_eq!((&d.fields["ot_hkna"], &d.fields["ot_ital"]), (&json!(true), &json!(false))); + assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["hkna", "-ital", "dlig"]), "untouched"); + d.fields.insert("ot_ital".into(), json!(true)); + d.fields.insert("ot_hkna".into(), Value::Null); + assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["dlig", "ital"])); + let mut d = dialog_for(json!({})); + assert!(d.fields["ot_hkna"].is_null() && d.fields["ot_ital"].is_null()); + d.fields.insert("ot_hkna".into(), json!(true)); + assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["hkna"])); + } + #[test] fn color_and_opentype_sections_show_their_controls() { let mut d = dialog_for(json!({})); @@ -2066,6 +2102,8 @@ mod tests { "Titling Alternates", "Ordinals", "Fractions", + "H or V Style", + "Roman Italics", "Stylistic Sets", "ss01", "ss20", diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 2c2673d468..cd089e04bd 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -23,7 +23,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { confirm, preview: true, // Load Texture… adds the texture it reads. - passes: |d, field| field == vectorcraft_effects::pixel::TEXTURE_IMAGE && matches!(d.str("__effect").as_str(), GLASS | TEXTURIZER), + passes: |d, field| field == vectorcraft_effects::pixel::TEXTURE_IMAGE && loads_texture(&d.str("__effect")), // OK could only fail the same way: off, beside the reason the dialog shows (#6140). ok_enabled: Some(|_, d| !(vectorcraft_effects::is_three_d(&d.str("__effect")) && d.fields.contains_key(form::REFUSED))), ..DialogSpec::FORM @@ -220,6 +220,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } None if id == GLASS => glass_fields(app, ui, d, GLASS), None if id == TEXTURIZER => glass_fields(app, ui, d, TEXTURIZER), + None if id == ROUGH_PASTELS => glass_fields(app, ui, d, ROUGH_PASTELS), None if id == TRANSFORM => transform_fields(ui, d, app.session.general_unit()), None if id == SCRIBBLE => scribble_fields(ui, d, app.session.general_unit()), None if id == ZIG_ZAG || id == ROUGHEN => size_fields(ui, d, &id, app.session.general_unit()), @@ -413,16 +414,23 @@ fn blur_center(ui: &mut egui::Ui, d: &mut Dialog) { const GLASS: &str = "distort.glass"; /// The Texturizer effect (Effect › Texture › Texturizer…). const TEXTURIZER: &str = "texture.texturizer"; +/// The Rough Pastels effect (Effect › Artistic › Rough Pastels…). +const ROUGH_PASTELS: &str = "artistic.roughPastels"; -/// Glass and Texturizer: parameters, the Texture menu ending with Loaded Texture once a texture is -/// loaded, and Load Texture… with the loaded texture's size. Returns whether the values differ from -/// those last previewed. +/// Does effect `id` have Load Texture… in its dialog (Glass, Texturizer, Rough Pastels)? +fn loads_texture(id: &str) -> bool { + matches!(id, GLASS | TEXTURIZER | ROUGH_PASTELS) +} + +/// Glass, Texturizer and Rough Pastels: parameters, the Texture menu ending with Loaded Texture +/// once a texture is loaded, and Load Texture… with the loaded texture's size. Returns whether the +/// values differ from those last previewed. fn glass_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &str) -> bool { use vectorcraft_effects::pixel::{TEXTURE_IMAGE, texture}; // The stored texture is shown as its size, not as a text field. let stored = d.fields.remove(TEXTURE_IMAGE); let size = stored.as_ref().and_then(Value::as_str).and_then(texture).map(|t| t.size()); - let menu = if id == TEXTURIZER { texturizer_textures(size.is_some()) } else { glass_textures(size.is_some()) }; + let menu = if id == GLASS { glass_textures(size.is_some()) } else { texturizer_textures(size.is_some()) }; let doc = vectorcraft_effects::effect_info(id).map(|e| e.params).unwrap_or_default(); let unit = app.session.general_unit(); let pick = |k: &str| if k == "texture" { Some(menu) } else { choices(id, k) }; @@ -438,11 +446,18 @@ fn glass_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: widgets::dim_label(ui, &format!("{w} × {h} px")); } }); - if load - && let Err(e) = crate::picks::in_dialog(app, d, load_texture) - && e != "cancelled" - { - app.status(e); + if load { + // The Effect Gallery's Settings draw in a dialog of their own: its answer goes to the gallery. + let loaded = if d.fields.contains_key(super::effect_gallery::SETTINGS) { + super::effect_gallery::load_texture(app, d) + } else { + crate::picks::in_dialog(app, d, load_texture) + }; + if let Err(e) = loaded + && e != "cancelled" + { + app.status(e); + } } values_changed(d) } @@ -453,7 +468,8 @@ fn glass_textures(loaded: bool) -> form::Choices { if loaded { &GLASS_TEXTURES_LOADED } else { &GLASS_TEXTURES } } -/// Texturizer's Texture menu, ending with Loaded Texture once a texture is `loaded`. +/// Texturizer's and Rough Pastels' Texture menu, ending with Loaded Texture once a texture is +/// `loaded`. fn texturizer_textures(loaded: bool) -> form::Choices { use vectorcraft_effects::pixel::{TEXTURES, TEXTURES_LOADED}; if loaded { &TEXTURES_LOADED } else { &TEXTURES } @@ -461,7 +477,7 @@ fn texturizer_textures(loaded: bool) -> form::Choices { /// Glass's Load Texture…: an image file the user picks, read into `textureImage` as Glass stores /// it and picked in the Texture menu. -fn load_texture(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { +pub(super) fn load_texture(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { use vectorcraft_engine::cmd::fileio; let filters = crate::FilePick::filters(fileio::FORMATS.iter().filter(|f| f.read && f.raster).map(|f| (f.label, fileio::read_extensions(f)))); let path = crate::picks::open(app, &crate::FilePick { filters, ..Default::default() }).ok_or("cancelled")?; @@ -488,8 +504,10 @@ fn choices(effect: &str, key: &str) -> Option { ("sketch.halftonePattern", "patternType") => Some(&vectorcraft_effects::pixel::HALFTONE_TYPES), ("sketch.plaster", "lightDirection") => Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS), ("texture.grain", "grainType") => Some(&vectorcraft_effects::pixel::GRAIN_TYPES), - ("texture.texturizer" | "artistic.underpainting" | "sketch.conteCrayon", "texture") => Some(&vectorcraft_effects::pixel::TEXTURES), - ("sketch.basRelief" | "texture.texturizer" | "artistic.underpainting" | "sketch.conteCrayon", "lightDirection") => { + ("texture.texturizer" | "artistic.underpainting" | "artistic.roughPastels" | "sketch.conteCrayon", "texture") => { + Some(&vectorcraft_effects::pixel::TEXTURES) + } + ("sketch.basRelief" | "texture.texturizer" | "artistic.underpainting" | "artistic.roughPastels" | "sketch.conteCrayon", "lightDirection") => { Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS) } ("video.deinterlace", "eliminate") => Some(&[("Odd Fields", "odd"), ("Even Fields", "even")]), @@ -547,10 +565,10 @@ pub(super) fn own_choices(effect: &str, key: &str, cmyk: bool) -> Option, cmyk: bool) -> Option> { if let Some(plugin) = vectorcraft_plugins::effect::installed(effect) { return plugin.manifest().param(key).and_then(|p| match &p.spec { @@ -561,7 +579,7 @@ pub(crate) fn shown_choices(effect: &str, key: &str, params: &Map if let Some(own) = own_choices(effect, key, cmyk) { return Some(own.into_iter().map(|(label, value)| (tl!(label).to_string(), value.to_string())).collect()); } - let options = if (effect == GLASS || effect == TEXTURIZER) && key == "texture" { + let options = if loads_texture(effect) && key == "texture" { use vectorcraft_effects::pixel::{TEXTURE_IMAGE, texture}; let loaded = params.get(TEXTURE_IMAGE).and_then(Value::as_str).and_then(texture).is_some(); if effect == GLASS { glass_textures(loaded) } else { texturizer_textures(loaded) } @@ -1185,6 +1203,45 @@ mod tests { assert_eq!((¶ms["texture"], ¶ms["textureImage"]), (&json!("loaded"), &json!(stored))); let _ = std::fs::remove_dir_all(&dir); } + + /// Rough Pastels' Texture and Light Direction are menus, and Load Texture… matches Glass: pick, + /// Loaded Texture in the menu with the texture's size, OK applies, and the Appearance panel's + /// Texture menu lists Loaded Texture too. + #[test] + fn rough_pastels_load_texture_reads_the_picked_image_into_the_effect() { + let dir = std::env::temp_dir().join(format!("vectorcraft-rough-pastels-dialog-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("texture.png"); + image::RgbaImage::from_fn(6, 4, |x, y| image::Rgba([(x * 40) as u8, (y * 60) as u8, 90, 255])).save(&path).unwrap(); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let picked = path.to_string_lossy().into_owned(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("effect.dialog", json!({"effect": ROUGH_PASTELS})).unwrap(); + let paint = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())); + let text = paint(&mut app); + assert!(text.lines().any(|l| l == "Load Texture…") && text.lines().any(|l| l == "Canvas") && text.lines().any(|l| l == "Top"), "{text}"); + assert!(!text.contains("Loaded Texture") && !text.contains(" px"), "{text}"); + let mut d = app.ui.dialog.take().unwrap(); + load_texture(&mut app, &mut d).unwrap(); + let stored = d.str(vectorcraft_effects::pixel::TEXTURE_IMAGE); + assert_eq!((d.str("texture"), vectorcraft_effects::pixel::texture(&stored).map(|t| t.size())), ("loaded".to_string(), Some((6, 4)))); + app.ui.dialog = Some(d); + let text = paint(&mut app); + assert!(text.lines().any(|l| l == "Loaded Texture") && text.lines().any(|l| l == "6 × 4 px"), "{text}"); + assert!(!text.contains(&stored[..24]), "the stored text isn't shown"); + super::super::confirm(&mut app).unwrap(); + let st = app.session.doc().unwrap(); + let params = &st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params; + assert_eq!((¶ms["texture"], ¶ms["textureImage"]), (&json!("loaded"), &json!(stored))); + let shown = shown_choices(ROUGH_PASTELS, "texture", params.as_object().unwrap(), false).unwrap(); + assert_eq!(shown.last().map(|(_, v)| v.as_str()), Some(vectorcraft_effects::pixel::LOADED), "{shown:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + /// Effect > Stylize > Outer Glow: a swatch beside Color shows the effect's colour, typing a /// hex recolours it, and a double-click opens the Color Picker over the dialog; its OK fills /// Color, and the effect applies that colour. diff --git a/crates/ui-egui/src/dialogs/effect_gallery.rs b/crates/ui-egui/src/dialogs/effect_gallery.rs index c78854d439..fe0de205fc 100644 --- a/crates/ui-egui/src/dialogs/effect_gallery.rs +++ b/crates/ui-egui/src/dialogs/effect_gallery.rs @@ -52,6 +52,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { #[derive(Clone)] struct StackEntry { + /// The row's own number, kept while the row moves (a file dialog's late answer finds it). + id: u64, effect: String, visible: bool, params: Map, @@ -63,13 +65,20 @@ pub fn open(app: &mut VectorcraftApp, _p: &Value) -> Result { return Err(tl!("Select artwork to apply effects").into()); } let selected = effects_in_category(0).first().map(|e| e.id).unwrap_or(""); + // Each gallery opened has a number of its own, so a file dialog's late answer for one that + // closed never reaches the next. + static OPENED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let opened = OPENED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); app.ui.dialog = Some(Dialog::new( KIND, - json!({ "category": 0, "selected": selected, "stack": [], "stackSelected": 0, "stackFocus": false, "preview": true }), + json!({ "category": 0, "selected": selected, "stack": [], "stackSelected": 0, "stackFocus": false, "preview": true, OPENED_FIELD: opened }), )); Ok(Value::Null) } +/// Field of the gallery holding the number it was opened with. +const OPENED_FIELD: &str = "opened"; + fn effects_in_category(cat: usize) -> Vec { let path = CATEGORIES.get(cat).map(|(_, p)| *p).unwrap_or(CATEGORIES[0].1); let mut out: Vec = @@ -92,31 +101,48 @@ fn merged_params(id: &str, stored: &Map) -> Map { } } +/// The stack's rows. A row without a number of its own, or with one an earlier row has, gets the +/// next number after the highest one. fn stack_of(d: &Dialog) -> Vec { - d.fields - .get("stack") - .and_then(Value::as_array) - .map(|rows| { - rows.iter() - .filter_map(|row| { - let effect = row.get("effect").and_then(Value::as_str)?; - let visible = row.get("visible").and_then(Value::as_bool).unwrap_or(true); - let params = row - .get("params") - .and_then(Value::as_object) - .cloned() - .map(|m| merged_params(effect, &m)) - .unwrap_or_else(|| default_params(effect)); - Some(StackEntry { effect: effect.to_string(), visible, params }) - }) - .collect() + let rows = d.fields.get("stack").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let mut next = rows.iter().filter_map(|row| row.get("id").and_then(Value::as_u64)).max().map_or(0, |m| m.saturating_add(1)); + next = next.max(d.fields.get(NEXT_ROW).and_then(Value::as_u64).unwrap_or(0)); + let mut seen = std::collections::HashSet::new(); + rows.iter() + .filter_map(|row| { + let effect = row.get("effect").and_then(Value::as_str)?; + let visible = row.get("visible").and_then(Value::as_bool).unwrap_or(true); + let params = + row.get("params").and_then(Value::as_object).cloned().map(|m| merged_params(effect, &m)).unwrap_or_else(|| default_params(effect)); + let id = match row.get("id").and_then(Value::as_u64) { + Some(id) if seen.insert(id) => id, + _ => { + let id = next; + next = next.saturating_add(1); + seen.insert(id); + id + } + }; + Some(StackEntry { id, effect: effect.to_string(), visible, params }) }) - .unwrap_or_default() + .collect() +} + +/// Field of the gallery holding the number the next new row takes. +const NEXT_ROW: &str = "nextRowId"; + +/// A number for a new row of `stack` in `d`: after the highest one, and never one that a removed +/// row had. +fn next_row_id(d: &Dialog, stack: &[StackEntry]) -> u64 { + let after = stack.iter().map(|e| e.id).max().map_or(0, |m| m.saturating_add(1)); + after.max(d.fields.get(NEXT_ROW).and_then(Value::as_u64).unwrap_or(0)) } fn write_stack(d: &mut Dialog, stack: &[StackEntry]) { - let rows: Vec = stack.iter().map(|e| json!({ "effect": e.effect, "visible": e.visible, "params": e.params })).collect(); + let rows: Vec = stack.iter().map(|e| json!({ "id": e.id, "effect": e.effect, "visible": e.visible, "params": e.params })).collect(); d.fields.insert("stack".into(), Value::Array(rows)); + let next = next_row_id(d, stack); + d.fields.insert(NEXT_ROW.into(), json!(next)); } fn category_ix(d: &Dialog) -> usize { @@ -177,8 +203,79 @@ fn write_param_target(d: &mut Dialog, stack: &mut [StackEntry], stack_sel: usize } } -/// Reuse the live effect dialog's fields for `effect_id` and `params`. -fn effect_param_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, effect_id: &str, params: &mut Map) { +/// Field of the effect dialog the gallery's Settings draw in ([`effect_param_fields`]): which +/// values they edit ([`settings_key`]). +pub(super) const SETTINGS: &str = "__gallerySettings"; + +/// Field of the stand-in dialog [`load_texture`] asks the file dialog with. +const ASKING: &str = "__asking"; + +/// The stack row the Settings of `d` edit when the stack has the focus, of `rows` rows. +fn settings_row(d: &Dialog, rows: usize) -> Option { + let row = (d.fields.get("stackSelected").and_then(Value::as_u64).unwrap_or(0) as usize).min(rows.saturating_sub(1)); + stack_focus(d).then_some(row) +} + +/// Which values the Settings of gallery `d` edit: `{opened, row}`, the gallery's number and the +/// number of the stack row, or `null` for the row when they edit the filter being browsed. +fn settings_key(d: &Dialog, stack: &[StackEntry]) -> Value { + let row = settings_row(d, stack.len()).and_then(|row| stack.get(row)).map(|e| e.id); + json!({ "opened": d.fields.get(OPENED_FIELD), "row": row }) +} + +/// Load Texture… in the gallery's Settings (`temp`, for Glass, Texturizer or Rough Pastels): the +/// image picked goes into `temp` when the file dialog answers at once. When it answers later +/// (shown off the UI thread) it goes into the values that asked: the stack row of that number +/// wherever it moved, or the filter being browsed. An answer for a row that was removed, or for a +/// filter no longer browsed, or for a gallery that closed, is dropped. +pub(super) fn load_texture(app: &mut VectorcraftApp, temp: &mut Dialog) -> Result<(), String> { + use vectorcraft_effects::pixel::TEXTURE_IMAGE; + let effect = temp.str("__effect"); + let key = temp.fields.get(SETTINGS).cloned().unwrap_or(Value::Null); + let opened = key.get("opened").cloned().unwrap_or(Value::Null); + let row = key.get("row").and_then(Value::as_u64); + let mut asking = Dialog::new(KIND, json!({ ASKING: true })); + crate::picks::in_dialog(app, &mut asking, move |app, d| { + if d.fields.contains_key(ASKING) { + return effect::load_texture(app, d); + } + let mut picked = Dialog::new(KIND, json!({})); + effect::load_texture(app, &mut picked)?; + if d.fields.get(OPENED_FIELD).unwrap_or(&Value::Null) != &opened { + return Ok(()); + } + match row { + Some(row) => { + let mut stack = stack_of(d); + if let Some(e) = stack.iter_mut().find(|e| e.id == row && e.effect == effect) { + e.params.extend(picked.fields); + write_stack(d, &stack); + } + } + None if selected_id(d) == effect => { + let mut params = if d.fields.get("browseEffect").and_then(Value::as_str) == Some(effect.as_str()) { + browse_params(d) + } else { + default_params(&effect) + }; + params.extend(picked.fields); + set_browse_params(d, &effect, params); + } + None => {} + } + Ok(()) + })?; + for key in [TEXTURE_IMAGE, "texture"] { + if let Some(v) = asking.fields.remove(key) { + temp.fields.insert(key.into(), v); + } + } + Ok(()) +} + +/// Reuse the live effect dialog's fields for `effect_id` and `params`, the values `key` names +/// ([`settings_key`]). +fn effect_param_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, effect_id: &str, params: &mut Map, key: Value) { if effect_id.is_empty() { return; } @@ -188,6 +285,7 @@ fn effect_param_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, effect_id: & fields.insert("__effect".into(), json!(effect_id)); fields.insert("__label".into(), json!(label)); fields.insert("preview".into(), json!(false)); + fields.insert(SETTINGS.into(), key); let mut temp = Dialog::new("effect", Value::Object(fields)); (effect::SPEC.body)(app, ui, &mut temp); if let Value::Object(m) = form::params(&temp) { @@ -475,8 +573,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let (param_effect, mut param_values) = param_target(d, &stack, stack_sel, &sel); if !param_effect.is_empty() { widgets::subheader(ui, tl!("Settings")); + let key = settings_key(d, &stack); egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { - effect_param_fields(app, ui, ¶m_effect, &mut param_values); + effect_param_fields(app, ui, ¶m_effect, &mut param_values, key); }); write_param_target(d, &mut stack, stack_sel, &sel, ¶m_effect, param_values); } @@ -604,7 +703,7 @@ fn add_selected(d: &mut Dialog, id: &str) { let params = if !stack_focus(d) && d.fields.get("browseEffect").and_then(Value::as_str) == Some(id) { browse_params(d) } else { default_params(id) }; let mut stack = stack_of(d); - stack.push(StackEntry { effect: id.to_string(), visible: true, params }); + stack.push(StackEntry { id: next_row_id(d, &stack), effect: id.to_string(), visible: true, params }); write_stack(d, &stack); d.fields.insert("stackSelected".into(), json!(stack.len().saturating_sub(1))); set_stack_focus(d, true); @@ -739,4 +838,88 @@ mod tests { ); assert_ne!(soft, hard, "the preview follows the filter's settings"); } + + /// Load Texture… in the Settings, with the file dialog shown off the UI thread: the image + /// goes into the values whose Settings asked (the filter browsed, or a stack row wherever it + /// moved) when the dialog answers, and an answer for a removed row, or for a filter no longer + /// browsed, is dropped. + #[test] + fn settings_load_texture_answers_into_the_gallery() { + use vectorcraft_effects::pixel::{TEXTURE_IMAGE, texture}; + let dir = std::env::temp_dir().join(format!("vectorcraft-gallery-texture-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("texture.png"); + image::RgbaImage::from_fn(6, 4, |x, y| image::Rgba([(x * 40) as u8, (y * 60) as u8, 90, 255])).save(&path).unwrap(); + let picked = path.to_string_lossy().into_owned(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut app.services); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap(); + app.run("ui.effectGalleryDialog", json!({})).unwrap(); + let effect = "artistic.roughPastels"; + let loaded = |params: &serde_json::Map| { + params.get(TEXTURE_IMAGE).and_then(serde_json::Value::as_str).and_then(texture).map(|t| t.size()) == Some((6, 4)) + && params.get("texture") == Some(&json!("loaded")) + }; + // Asks from the Settings of `row` (None: the filter browsed), then `meanwhile` changes the + // gallery before the dialog answers. + let ask = |app: &mut VectorcraftApp, row: Option, meanwhile: &dyn Fn(&mut crate::state::Dialog)| { + let gallery = app.ui.dialog.as_mut().unwrap(); + gallery.fields.remove("browseEffect"); + gallery.fields.remove("browseParams"); + gallery.fields.insert("selected".into(), json!(effect)); + gallery.fields.remove(super::NEXT_ROW); + gallery.fields.insert("stack".into(), json!([{"effect": effect, "params": {}}, {"effect": effect, "params": {}}])); + let rows = super::stack_of(gallery); + super::write_stack(gallery, &rows); + gallery.fields.insert("stackFocus".into(), json!(row.is_some())); + gallery.fields.insert("stackSelected".into(), json!(row.unwrap_or(0))); + let key = super::settings_key(gallery, &super::stack_of(gallery)); + let mut temp = crate::state::Dialog::new("effect", json!({"__effect": effect, super::SETTINGS: key, "texture": "canvas"})); + assert_eq!(super::load_texture(app, &mut temp), Err("cancelled".to_string()), "asked, not answered yet"); + assert!(temp.fields.get(TEXTURE_IMAGE).is_none()); + meanwhile(app.ui.dialog.as_mut().unwrap()); + shown.answer(&[picked.as_str()]); + crate::picks::poll(app, &egui::Context::default()); + let d = app.ui.dialog.as_ref().expect("the gallery stays open"); + (super::browse_params(d), super::stack_of(d).into_iter().map(|e| loaded(&e.params)).collect::>()) + }; + // The filter browsed gets it, and so does the stack row whose Settings asked. + let (browsed, rows) = ask(&mut app, None, &|_| {}); + assert!(loaded(&browsed) && rows == [false, false], "{browsed:?}"); + let (browsed, rows) = ask(&mut app, Some(1), &|_| {}); + assert!(!loaded(&browsed) && rows == [false, true], "{rows:?}"); + // The row that asked gets it after it moved, or after another row was selected. + let (_, rows) = ask(&mut app, Some(0), &|d| { + let mut stack = super::stack_of(d); + stack.swap(0, 1); + super::write_stack(d, &stack); + }); + assert_eq!(rows, [false, true], "moved down"); + let (_, rows) = ask(&mut app, Some(0), &|d| { + d.fields.insert("stackSelected".into(), json!(1)); + }); + assert_eq!(rows, [true, false], "another row selected"); + // A row removed (and another of the same effect added in its place), or a filter no + // longer browsed: dropped. + let (browsed, rows) = ask(&mut app, Some(1), &|d| { + let mut stack = super::stack_of(d); + stack.pop(); + super::write_stack(d, &stack); + super::add_selected(d, effect); + }); + assert!(!loaded(&browsed) && rows == [false, false], "{rows:?}"); + let (browsed, rows) = ask(&mut app, None, &|d| { + d.fields.insert("selected".into(), json!("artistic.cutout")); + }); + assert!(!loaded(&browsed) && rows == [false, false], "{browsed:?}"); + // The gallery that asked closed and another one opened. + let (browsed, rows) = ask(&mut app, Some(1), &|d| { + let next = d.fields.get(super::OPENED_FIELD).and_then(serde_json::Value::as_u64).unwrap() + 1; + d.fields.insert(super::OPENED_FIELD.into(), json!(next)); + }); + assert!(!loaded(&browsed) && rows == [false, false], "{rows:?}"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/ui-egui/src/display_profile.rs b/crates/ui-egui/src/display_profile.rs index 486489e6fe..b3f10ab27e 100644 --- a/crates/ui-egui/src/display_profile.rs +++ b/crates/ui-egui/src/display_profile.rs @@ -1,6 +1,8 @@ //! Preferences › Performance › Use Monitor Profile for Canvas: the canvas's art, its artboards' //! paper and the transparency grid are converted from sRGB into the ICC profile of the display -//! that shows most of the main window, as the last step before they are drawn. +//! that shows most of the main window, as the last step before they are drawn. Translucent art +//! and the anti-aliased edges of art over the paper or the grid are blended with it in sRGB first, +//! and the blend is converted ([`convert_over`]). //! //! The desktop app reads the display's profile on a background thread and hands the reading to //! [`DisplayProfile::report`], which checks the profile and builds its conversion table there. @@ -14,6 +16,7 @@ use std::sync::{Arc, RwLock}; use serde_json::{Value, json}; use vectorcraft_color::cms::display::DisplayTransform; pub use vectorcraft_color::cms::display::MAX_PROFILE_BYTES; +use vectorcraft_geom::{Affine, Point, Rect}; /// What the desktop app read for the display showing the main window. #[derive(Clone, Debug, PartialEq)] @@ -221,6 +224,264 @@ pub(crate) fn convert(t: Option<&DisplayTransform>, pixels: &mut [u8]) { t.apply_premultiplied(pixels); } +/// The document's paper under the canvas's art, which the canvas draws converted into the display +/// profile before the art: [`convert_over`] blends translucent art with it before converting. +/// The pasteboard and Outline's white canvas are interface colors and are not part of it. +#[derive(Clone, Debug, PartialEq)] +pub struct Backdrop { + /// View › Hide Artboards: the sRGB color of the paper that covers the whole canvas. + pub(crate) everywhere: Option<[u8; 3]>, + /// The artboards in the order they are drawn (the last on top), each with its paper's sRGB color. + pub(crate) artboards: Vec<(Rect, [u8; 3])>, + /// The transparency grid, which covers every artboard's paper: its two sRGB colors, and its cell + /// in screen points, counted from each artboard's top left corner. + pub(crate) grid: Option<([[u8; 3]; 2], f32)>, +} + +/// A color of the backdrop: in sRGB, and as the canvas shows it, converted. +#[derive(Clone, Copy, PartialEq)] +struct Paper { + srgb: [u8; 3], + shown: [u8; 3], +} + +/// An artboard of the backdrop, as [`Over`] looks it up. +struct Board { + /// Its rectangle in the document, normalized. + rect: Rect, + /// The corner the transparency grid's cells are counted from. + origin: Point, + paper: Paper, +} + +/// No artboard at a pixel of a row ([`Over::owners`]). +const NO_BOARD: u32 = u32::MAX; + +/// The first pixel at or after `x` that no artboard has taken yet, in the chain `next` (each +/// pixel's next candidate; a pixel not taken is its own), shortening the chain on the way. +fn untaken(next: &mut [u32], x: usize) -> usize { + let mut root = x; + while next[root] as usize != root { + root = next[root] as usize; + } + let mut at = x; + while next[at] as usize != root { + let n = next[at] as usize; + next[at] = root as u32; + at = n; + } + root +} + +/// [`Backdrop`] converted for a frame: what [`convert_over`] needs for each pixel. +struct Over<'a> { + t: &'a DisplayTransform, + /// The frame's pixels → the document. + to_doc: Affine, + everywhere: Option, + boards: Vec, + /// The grid's two colors and its cell in document points. + grid: Option<([Paper; 2], f64)>, +} + +impl<'a> Over<'a> { + /// For a frame of `width` × `height` pixels. None when the backdrop has no paper, or `view` + /// (the document → the pixels) can't be undone. The artboards that lie wholly outside the + /// frame are left out, so a row looks only at the artboards in view. + fn new(t: &'a DisplayTransform, b: &Backdrop, view: Affine, zoom: f64, (width, height): (usize, usize)) -> Option { + let det = view.determinant(); + if (b.everywhere.is_none() && b.artboards.is_empty()) || !det.is_finite() || det.abs() < 1e-12 { + return None; + } + let paper = |srgb: [u8; 3]| Paper { srgb, shown: t.apply8(srgb) }; + let to_doc = view.inverse(); + // The frame in the document, a pixel wider on every side than its pixels' centers reach. + let frame = to_doc.transform_rect_bbox(Rect::new(-1.0, -1.0, width as f64 + 1.0, height as f64 + 1.0)); + let in_view = |r: &Rect| r.x0 <= frame.x1 && frame.x0 <= r.x1 && r.y0 <= frame.y1 && frame.y0 <= r.y1; + let boards = b + .artboards + .iter() + .map(|&(r, color)| Board { rect: r.abs(), origin: r.origin(), paper: paper(color) }) + .filter(|b| in_view(&b.rect)) + .collect(); + let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 }; + Some(Self { + t, + to_doc, + everywhere: b.everywhere.map(paper), + boards, + grid: b.grid.map(|([a, c], cell)| ([paper(a), paper(c)], (f64::from(cell) / zoom).max(5e-10))), + }) + } + + /// The pixels `start..end` of the row whose centers lie at height `y` that artboard `b` covers, + /// their centers inside its rectangle. None when it covers none of the row's `width` pixels. + fn span(&self, b: &Board, y: f64, width: usize) -> Option<(usize, usize)> { + let at = self.to_doc * Point::new(0.0, y); + let [dx, dy, ..] = self.to_doc.as_coeffs(); + // Where along the row the document point stays within the rectangle, axis by axis. The + // rectangle holds its minimum edge and not its maximum one, so a row that runs toward the + // minimum holds its end there and not its start: each bound keeps whether it is held. + let (mut lo, mut hi) = ((f64::NEG_INFINITY, true), (f64::INFINITY, true)); + for (start, slope, min, max) in [(at.x, dx, b.rect.x0, b.rect.x1), (at.y, dy, b.rect.y0, b.rect.y1)] { + if slope == 0.0 { + if !(min <= start && start < max) { + return None; + } + continue; + } + let (t0, t1) = ((min - start) / slope, (max - start) / slope); + let (from, to) = if slope > 0.0 { ((t0, true), (t1, false)) } else { ((t1, false), (t0, true)) }; + // The tighter bound wins, and of two equal bounds the one not held. + if from.0 > lo.0 || (from.0 == lo.0 && !from.1) { + lo = from; + } + if to.0 < hi.0 || (to.0 == hi.0 && !to.1) { + hi = to; + } + } + // Pixel i's center is i + 0.5: the first pixel whose center is past `lo` (or on it, when + // held), up to the first one past `hi` (or on it, when not held). + let start = if lo.1 { (lo.0 - 0.5).ceil() } else { (lo.0 - 0.5).floor() + 1.0 }; + let end = if hi.1 { (hi.0 - 0.5).floor() + 1.0 } else { (hi.0 - 0.5).ceil() }; + let clamp = |v: f64| v.clamp(0.0, width as f64) as usize; + let (mut start, mut end) = (clamp(start), clamp(end)); + end = end.max(start); + // Rounding can put a bound a pixel away from where the rectangle's own test of each pixel's + // center puts it: move each end until the two agree. + let inside = |i: usize| b.rect.contains(self.to_doc * Point::new(i as f64 + 0.5, y)); + for _ in 0..2 { + if start < end && !inside(start) { + start += 1; + } else if start > 0 && inside(start - 1) { + start -= 1; + } + } + for _ in 0..2 { + if end > start && !inside(end - 1) { + end -= 1; + } else if end < width && inside(end) { + end += 1; + } + } + (start < end).then_some((start, end)) + } + + /// `owner` becomes the artboard on top at each of the `width` pixels of the row whose centers + /// lie at height `y` ([`NO_BOARD`] where there is none). Each pixel is taken once, by the + /// topmost artboard over it, so a row costs its width plus its artboards however they overlap. + fn owners(&self, y: f64, width: usize, owner: &mut Vec, next: &mut Vec) { + owner.clear(); + owner.resize(width, NO_BOARD); + next.clear(); + next.extend(0..=width as u32); + for (k, b) in self.boards.iter().enumerate().rev() { + let Some((start, end)) = self.span(b, y, width) else { continue }; + let mut x = untaken(next, start); + while x < end { + owner[x] = k as u32; + next[x] = x as u32 + 1; + x = untaken(next, x + 1); + } + } + } + + /// The paper at pixel `i` of the row at height `y`, whose artboard on top is `owner`. + fn paper_at(&self, owner: u32, i: usize, y: f64) -> Option { + let b = self.boards.get(owner as usize)?; + Some(match &self.grid { + Some((colors, cell)) => { + let p = self.to_doc * Point::new(i as f64 + 0.5, y); + let cells = ((p.x - b.origin.x) / cell).floor() + ((p.y - b.origin.y) / cell).floor(); + colors[usize::from(cells.rem_euclid(2.0) >= 1.0)] + } + None => b.paper, + }) + } + + /// Rows `first_row..` of the frame, `width` pixels each, in `pixels`. + fn rows(&self, pixels: &mut [u8], first_row: usize, width: usize) { + let mut last_px = ([0u8; 4], [0u8; 4]); + let mut last_mix = (([0u8; 4], [0u8; 3]), [0u8; 4]); + let (mut owner, mut next) = (Vec::new(), Vec::new()); + for (j, line) in pixels.chunks_exact_mut(width * 4).enumerate() { + let y = (first_row + j) as f64 + 0.5; + // Found for the row's first translucent pixel. + let mut found = false; + for (i, px) in line.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let a = px[3]; + if a == 0 { + continue; + } + let paper = if a == 255 { + None + } else if self.everywhere.is_some() { + self.everywhere + } else { + if !found { + self.owners(y, width, &mut owner, &mut next); + found = true; + } + self.paper_at(owner[i], i, y) + }; + match paper { + Some(p) if last_mix.0 == (*px, p.srgb) => *px = last_mix.1, + Some(p) => { + let input = (*px, p.srgb); + // Drawn over the converted paper, the pixel shows `px` over the paper in sRGB, converted. + let under = 255 - u32::from(a); + let mix = |c: usize| (u32::from(px[c]) + (u32::from(p.srgb[c]) * under + 127) / 255).min(255) as u8; + let shown = self.t.apply8([mix(0), mix(1), mix(2)]); + for ((o, s), u) in px.iter_mut().zip(shown).zip(p.shown) { + let paper_part = ((u32::from(u) * under + 127) / 255) as i32; + *o = (i32::from(s) - paper_part).clamp(0, 255) as u8; + } + last_mix = (input, *px); + } + None if last_px.0 == *px => *px = last_px.1, + None => { + let input = *px; + self.t.apply_premultiplied(px); + last_px = (input, *px); + } + } + } + } + } +} + +/// [`convert`], with each translucent pixel over the paper or the transparency grid of `backdrop` +/// blended with it in sRGB before the conversion. The canvas draws the art over the paper +/// converted: such a pixel keeps its alpha and takes the color that, drawn over the converted +/// paper, shows the blend converted. Opaque and fully transparent pixels, and pixels over the +/// pasteboard, convert as [`convert`] converts them. The pixels are rows of `width`; `view` maps the +/// document to them, and the backdrop's `zoom` is the canvas's screen points per document point, +/// in which the grid's cells are measured. +pub(crate) fn convert_over(t: Option<&DisplayTransform>, pixels: &mut [u8], width: u32, view: Affine, backdrop: Option<(&Backdrop, f64)>) { + let Some(t) = t else { return }; + let width = width as usize; + let over = backdrop + .filter(|_| width > 0 && pixels.len().is_multiple_of(width * 4)) + .and_then(|(b, zoom)| Over::new(t, b, view, zoom, (width, pixels.len() / (width * 4)))); + let Some(over) = over else { return convert(Some(t), pixels) }; + #[cfg(not(target_arch = "wasm32"))] + { + const PIECE: usize = 256 * 1024 * 4; + let threads = std::thread::available_parallelism().map_or(1, usize::from).min(16); + if threads > 1 && pixels.len() > PIECE { + let rows = (pixels.len() / (width * 4)).div_ceil(threads); + let over = &over; + std::thread::scope(|scope| { + for (k, piece) in pixels.chunks_mut(rows * width * 4).enumerate() { + scope.spawn(move || over.rows(piece, k * rows, width)); + } + }); + return; + } + } + over.rows(pixels, 0, width); +} + /// The opaque or translucent color `c` through `t`. pub(crate) fn color32(t: Option<&DisplayTransform>, c: egui::Color32) -> egui::Color32 { let Some(t) = t else { return c }; @@ -308,4 +569,211 @@ pub(crate) mod tests { let [r, g, b] = t.apply8([255, 0, 0]); assert_eq!(color32(Some(&t), egui::Color32::RED), egui::Color32::from_rgb(r, g, b)); } + + /// What the canvas shows of pixel `px` drawn over the converted paper `under`, as egui blends it. + fn shown_over(px: [u8; 4], under: [u8; 3]) -> [u32; 3] { + let k = 255 - u32::from(px[3]); + [0, 1, 2].map(|i| u32::from(px[i]) + (u32::from(under[i]) * k + 127) / 255) + } + + /// `px` blended with the sRGB paper `paper`, converted. + fn blended(t: &DisplayTransform, px: [u8; 4], paper: [u8; 3]) -> [u8; 3] { + let k = 255 - u32::from(px[3]); + t.apply8([0, 1, 2].map(|i| (u32::from(px[i]) + (u32::from(paper[i]) * k + 127) / 255).min(255) as u8)) + } + + fn assert_near(got: [u32; 3], want: [u8; 3], at: &str) { + assert!((0..3).all(|i| got[i].abs_diff(u32::from(want[i])) <= 1), "{at}: {got:?} shown, {want:?} wanted"); + } + + /// One row of `n` pixels, every one `px`. + fn row(n: usize, px: [u8; 4]) -> Vec { + px.repeat(n) + } + + #[test] + fn translucent_pixels_over_the_paper_are_blended_before_they_are_converted() { + let t = DisplayTransform::from_icc(&p3()).unwrap(); + let half_red = [128, 0, 0, 128]; + let b = Backdrop { everywhere: None, artboards: vec![(Rect::new(0.0, 0.0, 10.0, 1.0), [255; 3])], grid: None }; + let mut px = row(20, half_red); + px[12..16].copy_from_slice(&[255, 0, 0, 255]); + px[16..20].copy_from_slice(&[0; 4]); + convert_over(Some(&t), &mut px, 20, Affine::IDENTITY, Some((&b, 1.0))); + let at = |i: usize| -> [u8; 4] { px[i * 4..i * 4 + 4].try_into().unwrap() }; + let white = t.apply8([255; 3]); + assert_near(shown_over(at(0), white), blended(&t, half_red, [255; 3]), "over the paper"); + // Converted alone and drawn over the converted paper, the pixel shows another color. + let mut alone = half_red; + t.apply_premultiplied(&mut alone); + let want = blended(&t, half_red, [255; 3]); + assert!((0..3).any(|i| shown_over(alone, white)[i].abs_diff(u32::from(want[i])) > 2), "{alone:?}"); + assert_eq!(at(0)[3], 128, "the alpha is kept"); + // Opaque and empty pixels, and pixels over the pasteboard, convert as before. + let red = t.apply8([255, 0, 0]); + assert_eq!((at(3), at(4)), ([red[0], red[1], red[2], 255], [0; 4])); + assert_eq!(at(15), alone, "over the pasteboard"); + // Without a backdrop, and without a profile, as before. + let mut plain = row(20, half_red); + convert_over(Some(&t), &mut plain, 20, Affine::IDENTITY, None); + assert_eq!(plain, row(20, alone)); + let mut srgb = row(20, half_red); + convert_over(None, &mut srgb, 20, Affine::IDENTITY, Some((&b, 1.0))); + assert_eq!(srgb, row(20, half_red)); + } + + #[test] + fn the_backdrop_is_found_through_the_view() { + let t = DisplayTransform::from_icc(&p3()).unwrap(); + // Papers far apart: a pixel blended with the wrong one shows a color several levels off. + let half = [128, 0, 0, 128]; + let (light, dark) = ([255, 230, 230], [40, 40, 30]); + // A later artboard is drawn over an earlier one. + let b = Backdrop { + everywhere: None, + artboards: vec![(Rect::new(0.0, 0.0, 100.0, 100.0), light), (Rect::new(50.0, 0.0, 100.0, 100.0), dark)], + grid: None, + }; + // A view turned a quarter clockwise and moved: pixel x runs down the document's y axis. + let view = Affine::translate((10.0, 0.0)) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::translate((-20.0, -60.0)); + let mut px = row(10, half); + convert_over(Some(&t), &mut px, 10, view, Some((&b, 1.0))); + // Pixel (2.5, 0.5) is document point (20.5, 67.5): on the first artboard only. + let at = |i: usize| -> [u8; 4] { px[i * 4..i * 4 + 4].try_into().unwrap() }; + assert_near(shown_over(at(2), t.apply8(light)), blended(&t, half, light), "on the first artboard"); + let view = Affine::translate((-60.0, -5.0)); + let mut px = row(10, half); + convert_over(Some(&t), &mut px, 10, view, Some((&b, 1.0))); + let at = |i: usize| -> [u8; 4] { px[i * 4..i * 4 + 4].try_into().unwrap() }; + assert_near(shown_over(at(0), t.apply8(dark)), blended(&t, half, dark), "where the second covers the first"); + // View › Hide Artboards: the paper everywhere. + let all = Backdrop { everywhere: Some(dark), artboards: vec![], grid: None }; + let mut px = row(10, half); + convert_over(Some(&t), &mut px, 10, Affine::translate((-5000.0, 0.0)), Some((&all, 1.0))); + assert_near(shown_over(px[..4].try_into().unwrap(), t.apply8(dark)), blended(&t, half, dark), "Hide Artboards"); + // Overlapping artboards under a turned and scaled view, against a direct lookup of the + // artboard on top whose rectangle holds each pixel's center. + let boards: Vec<(Rect, [u8; 3])> = (0..12u8) + .map(|k| { + let (x, y) = (f64::from(k) * 7.0, f64::from(k % 3) * 9.0); + (Rect::new(x, y, x + 15.0, y + 20.0), if k % 2 == 0 { [255, 255, 255 - k] } else { [20 + k, 20, 30] }) + }) + .collect(); + let check = |boards: &[(Rect, [u8; 3])], view: Affine, (w, h): (usize, usize)| { + let b = Backdrop { everywhere: None, artboards: boards.to_vec(), grid: None }; + let mut px = row(w * h, half); + convert_over(Some(&t), &mut px, w as u32, view, Some((&b, 1.0))); + let to_doc = view.inverse(); + let mut on_paper = 0; + for i in 0..w * h { + let p = to_doc * Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + let mut want = half.to_vec(); + match boards.iter().rev().find(|(r, _)| r.contains(p)) { + Some(&(_, paper)) => { + let one = Backdrop { everywhere: Some(paper), artboards: vec![], grid: None }; + convert_over(Some(&t), &mut want, 1, Affine::IDENTITY, Some((&one, 1.0))); + on_paper += 1; + } + None => t.apply_premultiplied(&mut want), + } + assert_eq!(&px[i * 4..i * 4 + 4], &want[..], "pixel {i} at {p:?} through {view:?}"); + } + assert!(on_paper > 100 && on_paper < w * h, "{on_paper} pixels on the artboards"); + }; + check(&boards, Affine::rotate(0.3) * Affine::scale(1.7) * Affine::translate((5.0, -3.0)), (64, 48)); + // Rows that run backward across the artboards, with pixel centers on their edges: an edge + // at the minimum is on the artboard, one at the maximum is not. + check(&boards, Affine::new([-1.0, 0.0, 0.0, 1.0, 92.5, 0.0]), (96, 44)); + check(&boards, Affine::new([-1.0, 0.0, 0.0, -1.0, 92.5, 40.5]), (96, 44)); + // A sheared view where a computed bound falls on a pixel center that rounding puts just + // outside the artboard: the pixel's own center decides. + check(&[(Rect::new(-100.0, -2.0, 100.0, 100.0), [40, 40, 30])], Affine::new([3.0, 1.5, 0.1, 0.5, -0.3, 0.0]), (64, 48)); + } + + #[test] + fn the_transparency_grid_is_blended_cell_by_cell() { + let t = DisplayTransform::from_icc(&p3()).unwrap(); + let half = [128, 0, 0, 128]; + // Far apart, so that a pixel blended with the other cell's color shows several levels off. + let (light, dark) = ([255; 3], [40; 3]); + // Cells of 10 points from the artboard's corner at (5, 0), here 1 pixel a point. + let b = Backdrop { everywhere: None, artboards: vec![(Rect::new(5.0, 0.0, 65.0, 20.0), [255, 0, 0])], grid: Some(([light, dark], 10.0)) }; + let check = |zoom: f64, cells: [(usize, [u8; 3]); 3]| { + let mut px = row(60, half); + convert_over(Some(&t), &mut px, 60, Affine::IDENTITY, Some((&b, zoom))); + for (i, paper) in cells { + assert_near( + shown_over(px[i * 4..i * 4 + 4].try_into().unwrap(), t.apply8(paper)), + blended(&t, half, paper), + &format!("pixel {i} at {zoom}"), + ); + } + }; + check(1.0, [(6, light), (16, dark), (26, light)]); + // At 200% a cell is 5 document points. + check(2.0, [(6, light), (11, dark), (16, light)]); + } + + #[test] + fn large_frames_blend_the_same_on_several_threads() { + let t = DisplayTransform::from_icc(&p3()).unwrap(); + let b = Backdrop { + everywhere: None, + artboards: vec![(Rect::new(0.0, 0.0, 500.0, 900.0), [240, 230, 220])], + grid: Some(([[255; 3], [200; 3]], 8.0)), + }; + let (w, h) = (1000, 1000); + let mut big: Vec = (0..w * h * 4).map(|i| (i * 7 % 251) as u8).collect(); + for px in big.as_chunks_mut::<4>().0 { + let a = px[3]; + for c in &mut px[..3] { + *c = (*c).min(a); + } + } + let mut one = big.clone(); + Over::new(&t, &b, Affine::IDENTITY, 1.0, (w, h)).unwrap().rows(&mut one, 0, w); + convert_over(Some(&t), &mut big, w as u32, Affine::IDENTITY, Some((&b, 1.0))); + assert_eq!(big, one); + } + + #[test] + fn artboards_outside_the_frame_are_not_looked_up() { + let t = DisplayTransform::from_icc(&p3()).unwrap(); + let half = [128, 0, 0, 128]; + // A row of 1,000 artboards 30 points apart, each 20 wide and 40 high. + let artboards: Vec<(Rect, [u8; 3])> = (0..1000u32) + .map(|k| { + let x = f64::from(k) * 30.0; + (Rect::new(x, 0.0, x + 20.0, 40.0), [255 - (k % 200) as u8, 240, 230]) + }) + .collect(); + let b = Backdrop { everywhere: None, artboards: artboards.clone(), grid: None }; + // A turned frame of 64 × 48 pixels over the artboards near x = 15,000. + let view = Affine::rotate(0.4) * Affine::translate((-15_000.0, -5.0)); + let (w, h) = (64, 48); + let over = Over::new(&t, &b, view, 1.0, (w, h)).unwrap(); + assert!((1..=4).contains(&over.boards.len()), "{} artboards kept", over.boards.len()); + // Each pixel still uses the artboard its center is on. + let mut px = row(w * h, half); + convert_over(Some(&t), &mut px, w as u32, view, Some((&b, 1.0))); + let to_doc = view.inverse(); + let mut on_paper = 0; + for i in 0..w * h { + let p = to_doc * Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + let mut want = half.to_vec(); + match artboards.iter().rev().find(|(r, _)| r.contains(p)) { + Some(&(_, paper)) => { + let one = Backdrop { everywhere: Some(paper), artboards: vec![], grid: None }; + convert_over(Some(&t), &mut want, 1, Affine::IDENTITY, Some((&one, 1.0))); + on_paper += 1; + } + None => t.apply_premultiplied(&mut want), + } + assert_eq!(&px[i * 4..i * 4 + 4], &want[..], "pixel {i} at {p:?}"); + } + assert!(on_paper > 100 && on_paper < w * h, "{on_paper} pixels on the artboards"); + // A frame over none of the artboards keeps none, and converts as without a backdrop. + let away = Affine::translate((0.0, -500.0)); + assert_eq!(Over::new(&t, &b, away, 1.0, (w, h)).map(|o| o.boards.len()), Some(0)); + } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 119560f58e..e455157e14 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -631,6 +631,7 @@ font-technology Variable Променливи Copy All Styles Копиране на всички стилове Copy Color Value (Hex) Копиране на цветова стойност (Hex) Copy Fonts Used in the Document Копиране на шрифтовете, използвани в документа + Copy Info Копиране на информация Copy Links Копиране на връзките Copy Selected Style Копиране на избрания стил Copy to User Defined Копиране в потребителски @@ -2592,6 +2593,8 @@ font-technology Variable Променливи Proof Setup Настройка на пробен печат Properties Свойства Proportional Metrics Пропорционални метрики + H or V Style Хоризонтален или вертикален стил + Roman Italics Римски курсив Insert Aki Left Вмъкване на аки отляво Insert Aki Right Вмъкване на аки отдясно Tsume Цуме @@ -4182,6 +4185,7 @@ axis Both И двете Stop the script and undo what it did? Then quit again. Да се спре ли скриптът и да се отменят ли промените му? След това излезте отново. This script has added a great many objects. Continue, or stop it and undo what it did? Този скрипт добави изключително много обекти. Да продължи ли, или да се спре и да се отменят промените му? @msg Copied the script report Копиран е отчетът за скрипта +@msg Copied the link info Информацията за връзката е копирана @msg Corners edits the corners of a path Ъглите редактират ъглите на пътя @msg Couldn't copy to the system clipboard: {e} Неуспешно копиране в системния клипборд: {e} @msg Couldn't open {name}: {e} Неуспешно отваряне на {name}: {e} diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 72e51d8945..f126736293 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1584,6 +1584,8 @@ lighting Light Svjetlo Leading Prored No Break Bez preloma Proportional Metrics Proporcionalna metrika + H or V Style Horizontalni ili vertikalni stil + Roman Italics Latinični kurziv Insert Aki Left Umetni aki lijevo Insert Aki Right Umetni aki desno Tsume Tsume @@ -2700,6 +2702,7 @@ digits Malayalam Malajalam Transparent Background Prozirna pozadina Choose… Odaberi… Copy Fonts Used in the Document Kopiraj fontove korištene u dokumentu + Copy Info Kopiraj informacije Copy Links Kopiraj veze Create Report Kreiraj izvještaj Fonts whose licence doesn't allow embedding are not copied. Fontovi čija licenca ne dozvoljava ugrađivanje ne kopiraju se. @@ -4547,6 +4550,7 @@ imageTrace Outline Obris Stop the script and undo what it did? Then quit again. Zaustaviti skriptu i poništiti ono što je uradila? Zatim ponovo izađite. This script has added a great many objects. Continue, or stop it and undo what it did? Ova skripta je dodala veoma mnogo objekata. Nastaviti, ili je zaustaviti i poništiti ono što je uradila? @msg Copied the script report Izvještaj skripte kopiran +@msg Copied the link info Informacije o vezi kopirane @msg Corners edits the corners of a path Uglovi uređuje uglove putanje @msg Couldn't copy to the system clipboard: {e} Nije moguće kopirati u sistemski međuspremnik: {e} @msg Couldn't open {name}: {e} Nije moguće otvoriti {name}: {e} diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 02c37b9b65..9dc7bef3cf 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -930,6 +930,7 @@ antiAlias Sharp Nítid Copy Guide Copia la guia Copy in Perspective Copia en perspectiva Copy into the document's folder Copia a la carpeta del document + Copy Info Copia la informació Copy Links Copia els enllaços Copy only fonts you own or are licensed to install. Copia només els tipus de lletra que tinguis o que tinguis llicència per instal·lar. Copy Report Copia l’informe @@ -3361,6 +3362,8 @@ digits Malayalam Malaiàlam Proof Setup Configuració de prova Properties Propietats Proportional Metrics Mètriques proporcionals + H or V Style Estil horitzontal o vertical + Roman Italics Cursiva romana Insert Aki Left Insereix aki a l'esquerra Insert Aki Right Insereix aki a la dreta Tsume Tsume @@ -5598,6 +5601,7 @@ digits Malayalam Malaiàlam @msg there is no script engine named {name} no hi ha cap motor d'scripts anomenat {name} @msg Running a script S'està executant un script @msg Copied the script report S’ha copiat l’informe de l’script +@msg Copied the link info S’ha copiat la informació de l’enllaç @msg Corners edits the corners of a path Cantonades edita les cantonades d’un traçat @msg could not decode image: {0} no s’ha pogut descodificar la imatge: {0} @msg Couldn't copy to the system clipboard: {e} No s’ha pogut copiar al porta-retalls del sistema: {e} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 334d33f2f5..d82f5628c5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1612,6 +1612,8 @@ lighting Light Světlo Leading Proklad No Break Nezalamovat Proportional Metrics Proporcionální metrika + H or V Style Vodorovný nebo svislý styl + Roman Italics Kurzíva latinky Insert Aki Left Vložit aki vlevo Insert Aki Right Vložit aki vpravo Tsume Tsume @@ -2735,6 +2737,7 @@ digits Malayalam Malajálamské Transparent Background Průhledné pozadí Choose… Vybrat… Copy Fonts Used in the Document Kopírovat písma použitá v dokumentu + Copy Info Kopírovat informace Copy Links Kopírovat propojené soubory Create Report Vytvořit zprávu Fonts whose licence doesn't allow embedding are not copied. Písma, jejichž licence nepovoluje vkládání, se nekopírují. @@ -4704,6 +4707,7 @@ imageTrace Outline Obrys @msg there is no script engine named {name} skriptovací jádro s názvem {name} neexistuje @msg Running a script Běží skript @msg Copied the script report Zpráva o skriptu zkopírována +@msg Copied the link info Informace o propojení zkopírovány @msg Corners edits the corners of a path Rohy upravuje rohy cesty @msg Couldn't copy to the system clipboard: {e} Nelze kopírovat do systémové schránky: {e} @msg Couldn't open {name}: {e} {name} nelze otevřít: {e} @@ -9256,7 +9260,7 @@ breakText Glyphs Glyfy @msg unexpected resources neočekávané prostředky @msg unknown CMYK profile `{name}` neznámý profil CMYK `{name}` @msg unknown File Info field `{k}` neznámé pole informací o souboru `{k}` -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) neznámá funkce OpenType `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; předpona - funkci vypne; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom a aalt přijímají číslo alternativy jako `tag=N` s N od 0 do {_1}, například `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) neznámá funkce OpenType `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; předpona - funkci vypne; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom a aalt přijímají číslo alternativy jako `tag=N` s N od 0 do {_1}, například `salt=2`) @msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) neznámá funkce OpenType `{t}` ve featureChanges (značky, které přijímá `features`) @msg unknown PDF preset `{name}` (presets: {_1}) neznámé přednastavení PDF `{name}` (přednastavení: {_1}) @msg unknown Warp style neznámý styl deformace diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 781858609c..f327d93098 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2129,6 +2129,8 @@ lighting Light Lys Leading Linjeafstand No Break Ingen ombrydning Proportional Metrics Proportionale metrikker + H or V Style Vandret eller lodret stil + Roman Italics Romersk kursiv Insert Aki Left Indsæt aki til venstre Insert Aki Right Indsæt aki til højre Tsume Tsume @@ -3126,6 +3128,7 @@ digits Malayalam Malayalam Transparent Background Gennemsigtig baggrund Choose… Vælg… Copy Fonts Used in the Document Kopiér skrifttyper, der bruges i dokumentet + Copy Info Kopiér info Copy Links Kopiér links Create Report Opret rapport Fonts whose licence doesn't allow embedding are not copied. Skrifttyper, hvis licens ikke tillader indlejring, kopieres ikke. @@ -4908,6 +4911,7 @@ imageTrace Outline Kontur Stop the script and undo what it did? Then quit again. Stop scriptet og fortryd det, det har gjort? Afslut derefter igen. This script has added a great many objects. Continue, or stop it and undo what it did? Dette script har tilføjet rigtig mange objekter. Fortsæt, eller stop det og fortryd det, det har gjort? @msg Copied the script report Kopierede scriptrapporten +@msg Copied the link info Kopierede linkinfo @msg Corners edits the corners of a path Hjørner redigerer hjørnerne på en kurve @msg Couldn't copy to the system clipboard: {e} Kunne ikke kopiere til systemets udklipsholder: {e} @msg Couldn't open {name}: {e} Kunne ikke åbne {name}: {e} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 755a52205a..2730b5fa4c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -624,6 +624,7 @@ font-technology Variable Variabel Copy All Styles Alle Stile kopieren Copy Color Value (Hex) Farbwert kopieren (Hex) Copy Fonts Used in the Document Im Dokument verwendete Schriften kopieren + Copy Info Informationen kopieren Copy Links Verknüpfungen kopieren Copy Selected Style Ausgewählten Stil kopieren Copy to User Defined In „Benutzerdefiniert“ kopieren @@ -2636,6 +2637,8 @@ mark-style Japanese Japanisch Proof Setup Proof einrichten Properties Eigenschaften Proportional Metrics Proportionale Metrik + H or V Style Horizontaler oder vertikaler Stil + Roman Italics Lateinische Kursive Insert Aki Left Aki links einfügen Insert Aki Right Aki rechts einfügen Tsume Tsume @@ -4316,6 +4319,7 @@ theme Appearance Erscheinungsbild @msg there is no script engine named {name} es gibt keine Skript-Engine namens {name} @msg Running a script Skript wird ausgeführt @msg Copied the script report Skriptbericht kopiert +@msg Copied the link info Verknüpfungsinformationen kopiert @msg Corners edits the corners of a path „Ecken“ bearbeitet die Ecken eines Pfads @msg Couldn't copy to the system clipboard: {e} In die Systemzwischenablage konnte nicht kopiert werden: {e} @msg Couldn't open {name}: {e} {name} konnte nicht geöffnet werden: {e} @@ -7476,7 +7480,7 @@ breakText Glyphs Glyphen @msg toString is not a function toString ist keine Funktion @msg too many arguments zu viele Argumente @msg too many quantifiers zu viele Quantifizierer -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) unbekannte OpenType-Funktion `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; mit vorangestelltem - ausschalten; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom und aalt nehmen die Nummer einer Alternative als `tag=N` mit N von 0 bis {_1}, etwa `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) unbekannte OpenType-Funktion `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; mit vorangestelltem - ausschalten; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom und aalt nehmen die Nummer einer Alternative als `tag=N` mit N von 0 bis {_1}, etwa `salt=2`) @msg unknown dither `{d}` (diffusion, pattern, noise or none) unbekanntes Dithering `{d}` (diffusion, pattern, noise oder none) @msg unknown panel `{panel}` unbekanntes Bedienfeld `{panel}` @msg unknown parameter `{key}` unbekannter Parameter `{key}` diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 50f3bdd567..5bd9bac4e6 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -605,6 +605,7 @@ font-technology Variable Μεταβλητές Copy All Styles Αντιγραφή όλων των στυλ Copy Color Value (Hex) Αντιγραφή τιμής χρώματος (Hex) Copy Fonts Used in the Document Αντιγραφή γραμματοσειρών που χρησιμοποιούνται στο έγγραφο + Copy Info Αντιγραφή πληροφοριών Copy Links Αντιγραφή συνδέσεων Copy Selected Style Αντιγραφή επιλεγμένου στυλ Copy to User Defined Αντιγραφή σε ορισμένα από τον χρήστη @@ -2449,6 +2450,7 @@ axis Both Και τα δύο @msg there is no script engine named {name} δεν υπάρχει μηχανή σεναρίων με όνομα {name} @msg Running a script Εκτελείται σενάριο @msg Copied the script report Η αναφορά του σεναρίου αντιγράφηκε +@msg Copied the link info Οι πληροφορίες συνδέσμου αντιγράφηκαν @msg Corners edits the corners of a path Η εντολή Γωνίες επεξεργάζεται τις γωνίες μιας διαδρομής @msg Couldn't copy to the system clipboard: {e} Δεν ήταν δυνατή η αντιγραφή στο πρόχειρο συστήματος: {e} @msg Couldn't open {name}: {e} Δεν ήταν δυνατό το άνοιγμα του {name}: {e} @@ -3432,6 +3434,8 @@ theme Appearance Εμφάνιση Profile inclusion: Συμπερίληψη προφίλ: Projecting Cap Προεξέχον άκρο Proportional Metrics Αναλογικές μετρικές + H or V Style Οριζόντιο ή κατακόρυφο στυλ + Roman Italics Λατινικά πλάγια Insert Aki Left Εισαγωγή aki αριστερά Insert Aki Right Εισαγωγή aki δεξιά Tsume Tsume diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index fc422c1594..c4dc5a6297 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -646,6 +646,7 @@ font-technology Variable Variables Copy All Styles Copiar todos los estilos Copy Color Value (Hex) Copiar valor de color (hex) Copy Fonts Used in the Document Copiar fuentes usadas en el documento + Copy Info Copiar información Copy Links Copiar vínculos Copy Selected Style Copiar estilo seleccionado Copy to User Defined Copiar en Definido por el usuario @@ -2670,6 +2671,8 @@ font-technology Variable Variables Proof Setup Ajuste de prueba Properties Propiedades Proportional Metrics Métrica proporcional + H or V Style Estilo horizontal o vertical + Roman Italics Cursiva romana Insert Aki Left Insertar aki a la izquierda Insert Aki Right Insertar aki a la derecha Tsume Tsume @@ -4393,6 +4396,7 @@ axis Both Ambos @msg there is no script engine named {name} no hay ningún motor de scripts llamado {name} @msg Running a script Ejecutando un script @msg Copied the script report Se copió el informe del script +@msg Copied the link info Se copió la información del vínculo @msg Corners edits the corners of a path Esquinas edita las esquinas de un trazado @msg Couldn't copy to the system clipboard: {e} No se pudo copiar al portapapeles del sistema: {e} @msg Couldn't open {name}: {e} No se pudo abrir {name}: {e} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 95ca8ef58d..ed75230a89 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1587,6 +1587,8 @@ lighting Light Valo Leading Riviväli No Break Ei katkoa Proportional Metrics Suhteelliset metriikat + H or V Style Vaaka- tai pystytyyli + Roman Italics Latinalainen kursiivi Insert Aki Left Lisää aki vasemmalle Insert Aki Right Lisää aki oikealle Tsume Tsume @@ -2679,6 +2681,7 @@ digits Malayalam Malajalam Transparent Background Läpinäkyvä tausta Choose… Valitse… Copy Fonts Used in the Document Kopioi asiakirjassa käytetyt fontit + Copy Info Kopioi tiedot Copy Links Kopioi linkit Create Report Luo raportti Fonts whose licence doesn't allow embedding are not copied. Fontteja, joiden lisenssi ei salli upottamista, ei kopioida. @@ -4618,6 +4621,7 @@ imageTrace Outline Ääriviiva @msg there is no script engine named {name} skriptimoottoria nimeltä {name} ei ole @msg Running a script Suoritetaan komentosarjaa @msg Copied the script report Komentosarjan raportti kopioitiin +@msg Copied the link info Linkin tiedot kopioitiin @msg Corners edits the corners of a path Kulmat-toiminto muokkaa polun kulmia @msg Couldn't copy to the system clipboard: {e} Järjestelmän leikepöydälle ei voitu kopioida: {e} @msg Couldn't open {name}: {e} Tiedostoa {name} ei voitu avata: {e} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6d0ad79adb..421095c5a8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -647,6 +647,7 @@ font-technology Variable Variables Copy All Styles Copier tous les styles Copy Color Value (Hex) Copier la valeur de couleur (hex) Copy Fonts Used in the Document Copier les polices utilisées dans le document + Copy Info Copier les informations Copy Links Copier les liens Copy Selected Style Copier le style sélectionné Copy to User Defined Copier dans Défini par l’utilisateur @@ -2662,6 +2663,8 @@ font-technology Variable Variables Proof Setup Format d’épreuve Properties Propriétés Proportional Metrics Métrique proportionnelle + H or V Style Style horizontal ou vertical + Roman Italics Italique des caractères romains Insert Aki Left Insérer un aki à gauche Insert Aki Right Insérer un aki à droite Tsume Tsume @@ -4382,6 +4385,7 @@ axis Both Les deux @msg there is no script engine named {name} il n'y a aucun moteur de script nommé {name} @msg Running a script Exécution d’un script @msg Copied the script report Rapport du script copié +@msg Copied the link info Informations sur le lien copiées @msg Corners edits the corners of a path Angles modifie les angles d’un tracé @msg Couldn't copy to the system clipboard: {e} Impossible de copier dans le presse-papiers du système : {e} @msg Couldn't open {name}: {e} Impossible d’ouvrir {name} : {e} @@ -9114,7 +9118,7 @@ breakText Glyphs Glyphes @msg unexpected resources ressources inattendues @msg unknown CMYK profile `{name}` profil CMJN inconnu `{name}` @msg unknown File Info field `{k}` champ Informations sur le fichier inconnu `{k}` -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) fonctionnalité OpenType inconnue `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt ; préfixe - pour désactiver ; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom et aalt acceptent un numéro de variante sous la forme `tag=N` avec N de 0 à {_1}, comme `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) fonctionnalité OpenType inconnue `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital ; préfixe - pour désactiver ; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom et aalt acceptent un numéro de variante sous la forme `tag=N` avec N de 0 à {_1}, comme `salt=2`) @msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) fonctionnalité OpenType inconnue `{t}` dans featureChanges (les balises qu’accepte `features`) @msg unknown PDF preset `{name}` (presets: {_1}) paramètre prédéfini PDF inconnu `{name}` (paramètres prédéfinis : {_1}) @msg unknown Warp style style de déformation inconnu diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index aface16abc..6d43bfce86 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -743,6 +743,7 @@ Copy Artboard આર્ટબોર્ડ નકલ કરો Copy Color Value (Hex) રંગ મૂલ્યની નકલ કરો (Hex) Copy Guide ગાઇડ નકલ કરો + Copy Info માહિતી નકલ કરો Copy Report રિપોર્ટ નકલ કરો Copy Script Console સ્ક્રિપ્ટ કન્સોલ નકલ કરો Copy Selected પસંદ કરેલું નકલ કરો @@ -2743,6 +2744,8 @@ Proof Setup પ્રૂફ સેટઅપ Properties ગુણધર્મો Proportional Metrics પ્રમાણસર મેટ્રિક્સ + H or V Style આડી અથવા ઊભી શૈલી + Roman Italics રોમન ઇટાલિક Pucker પકર Pucker & Bloat… પકર અને બ્લોટ… Pucker Tool પકર ટૂલ diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9839a1d4ad..87553a4c84 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -763,8 +763,8 @@ antiAlias Sharp शार्प Change Case प्रकरण बदलें Change Panel Layout पैनल लेआउट बदलें Change Screen Mode (F) स्क्रीन मोड बदलें (F) - Change to Artboard Rulers आर्टबोर्ड शासकों में परिवर्तन - Change to Global Rulers वैश्विक शासकों में परिवर्तन + Change to Artboard Rulers आर्टबोर्ड रूलर्स में परिवर्तन + Change to Global Rulers ग्लोबल रूलर्स में परिवर्तन Change to Left-to-Right Layout लेफ्ट-टू-राइट लेआउट में परिवर्तन Change to Right-to-Left Layout राइट-टू-लेफ्ट लेआउट में परिवर्तन Changed since the last check. आखिरी चेक के बाद से बदल गया। @@ -780,12 +780,12 @@ antiAlias Sharp शार्प Channel 4 चैनल 4 Character कैरेक्टर Character / Paragraph कैरेक्टर / पैराग्राफ - Character Alignment चरित्र संरेखण - Character Color चरित्र रंग - Character Direction चरित्र निर्देशन - Character options चरित्र ऑप्शन्स - Character Rotation चरित्र रोटेशन - Character Set: चरित्र सेट: + Character Alignment कैरेक्टर संरेखण + Character Color कैरेक्टर रंग + Character Direction कैरेक्टर दिशा + Character options कैरेक्टर ऑप्शन्स + Character Rotation कैरेक्टर रोटेशन + Character Set: कैरेक्टर सेट: Character Style कैरेक्टर स्टाइल Character Style Options कैरेक्टर स्टाइल ऑप्शन्स Character Styles कैरेक्टर स्टाइल्स @@ -975,7 +975,7 @@ antiAlias Sharp शार्प Compatible Gradient and Gradient Mesh Printing कंपैटिबल ग्रेडिएंट और ग्रेडिएंट मेश प्रिंटिंग Compiled or not text संकलित या टेक्स्ट नहीं Compiled script: ask its author for the .jsx file कंपाइल की हुई स्क्रिप्ट: इसके लेखक से .jsx फ़ाइल माँगें - Complement संगम + Complement पूरक Complementary पूरक Complementary 2 पूरक 2 Complete पूरा करना @@ -992,18 +992,18 @@ antiAlias Sharp शार्प Compression संपीड़न Compression › Color › Above Ppi संपीड़न › रंग › पीपीआई के ऊपर Compression: संपीड़न: - Concave अवतरण + Concave अवतल Concentric Dividers: सांद्रिक विभाजक: Condition identifier: शर्त पहचानकर्ता: Configure Shape आकार को कॉन्फ़िगर करें Conflict {n} of {count} {count} में से विरोध {n} Connect कनेक्ट - Connect Selected End Points कनेक्ट सिलेक्टेड अंत अंक + Connect Selected End Points सिलेक्टेड एंड पॉइंट्स कनेक्ट करें Consider Open Filled Paths as Closed बंद के रूप में ओपन भरा पाथ पर विचार करें Consolidate All Windows सभी विंडो एक साथ लाएँ - Constrain कंस्ट्रक्शन - Constrain Angle कंस्ट्रक्शन एंगल - Constrain Width and Height Proportions कंस्ट्रक्शन चौड़ाई और ऊंचाई के अनुपात + Constrain सीमित करें + Constrain Angle कंस्ट्रेन एंगल + Constrain Width and Height Proportions चौड़ाई और ऊंचाई का अनुपात बनाए रखें Construction Guides निर्माण गाइड Conté Crayon कॉन्टे क्रेयॉन Conté Crayon… कॉन्टे क्रेयॉन… @@ -1061,6 +1061,7 @@ antiAlias Sharp शार्प Copy Guide कॉपी गाइड Copy in Perspective पर्सपेक्टिव में प्रतिलिपि Copy into the document's folder डॉक्यूमेंट के फ़ोल्डर में कॉपी करें + Copy Info जानकारी कॉपी करें Copy Links कॉपी लिंक Copy only fonts you own or are licensed to install. केवल उन फ़ॉन्टों को कॉपी करें जिन्हें आप स्वयं करते हैं या इंस्टॉल करने के लिए लाइसेंस प्राप्त हैं। Copy Report रिपोर्ट कॉपी करें @@ -1182,7 +1183,7 @@ antiAlias Sharp शार्प Cut Artboard कट आर्टबोर्ड Rename Artboards आर्टबोर्ड्स का नाम बदलें Cut Path कट पाथ - Cut Path at Selected Anchor Points सिलेक्टेड एंकर अंक पर पाथ काटें + Cut Path at Selected Anchor Points सिलेक्टेड एंकर पॉइंट्स पर पाथ काटें Cutout कटआउट Cutout… कटआउट… Cyan सियान @@ -1326,7 +1327,7 @@ antiAlias Sharp शार्प Description: विवरण: Deselect डीसिलेक्ट करें Design… डिजाइन… - Desired देसी + Desired वांछित Destination Folder: गंतव्य फ़ोल्डर: Destination: गंतव्य: Detail डिटेल @@ -1355,7 +1356,7 @@ antiAlias Sharp शार्प Dimensions आयाम Direct Selection प्रत्यक्ष सिलेक्शन Direct Selection Tool डायरेक्ट सिलेक्शन टूल - Direct-select anchor points to edit them. उन्हें संपादित करने के लिए डायरेक्ट-सिलेक्शन एंकर अंक। + Direct-select anchor points to edit them. एंकर पॉइंट्स को संपादित करने के लिए उन्हें डायरेक्ट-सिलेक्ट करें। Direction Balance दिशा संतुलन Direction Handles निर्देशन हैंडल Direction: दिशा: @@ -1686,7 +1687,7 @@ antiAlias Sharp शार्प Envelope #10 लिफाफाफा #10 Envelope Distort एन्वेलप डिस्टॉर्ट Envelope DL लिफ़ाफ़ा DL - Envelope Info लिफाफे की जानकारी + Envelope Info एन्वेलप जानकारी Envelope Mesh एन्वेलप मेश Envelope Options एन्वेलप ऑप्शन्स Envelope Options… एन्वेलप ऑप्शन्स… @@ -1697,7 +1698,7 @@ antiAlias Sharp शार्प Eraser Tool इरेज़र टूल Error त्रुटि Esc Esc - Even शाम + Even बराबर Even Fields सम फ़ील्ड्स Even-Numbered Anchors यहां तक कि संख्या वाले एंकर Event: कार्यक्रम: @@ -1872,7 +1873,7 @@ antiAlias Sharp शार्प Fill new strokes नए स्ट्रोक भरें Fill New Type Objects With Placeholder Text नए टाइप ऑब्जेक्ट्स में प्लेसहोल्डर टेक्स्ट भरें Fill Pattern पैटर्न भरें - Fill Rule नियम भरें + Fill Rule फ़िल रूल Fill with Placeholder Text प्लेसहोल्डर टेक्स्ट से भरें Fill: फ़िल: Fill: click for swatches, Shift-click for the color mixer फ़िल: स्वॉचेस के लिए क्लिक करें, कलर मिक्सर के लिए Shift-क्लिक करें @@ -1880,7 +1881,7 @@ antiAlias Sharp शार्प Filled Open Paths भरा खुला पाथ Filled Square भरा वर्ग Filling in form fields and signing फॉर्म फ़ील्ड में भरना और साइन करना - Fills भरता + Fills फ़िल्स Film & Video फ़िल्म और वीडियो Film Grain… फ़िल्म ग्रेन… Filter फ़िल्टर @@ -1984,7 +1985,7 @@ antiAlias Sharp शार्प fonts not copied: {fonts} फ़ॉन्ट कॉपी नहीं हुए: {fonts} Fonts whose licence doesn't allow embedding are not copied. जिन फ़ॉन्ट का लाइसेंस एम्बेडिंग की अनुमति नहीं देता है, उन्हें कॉपी नहीं किया जाता है। For Fractions: फ्रैक्शन के लिए: - Force सेना + Force बलपूर्वक Forced Line Break जबरन लाइन ब्रेक Foreground Level फ़ोरग्राउंड स्तर Forget Import Matches फॉरगेट आयात मैच @@ -2023,7 +2024,7 @@ antiAlias Sharp शार्प Full Width पूर्ण चौड़ाई Fully Inside पूरी तरह से अंदर Fully Outside पूरी तरह बाहर - Function Key: समारोह कुंजी: + Function Key: फ़ंक्शन की: Fuzziness: फ़ज़ीनेस: Gamma गामा Gamut Check गैमट जाँच @@ -2221,8 +2222,8 @@ antiAlias Sharp शार्प Hide Gradient Annotator ग्रेडिएंट एनोटेटर छिपाएँ Hide Grid ग्रिड छिपाएँ Hide Guides गाइड्स छिपाएँ - Hide Hidden Characters छिपे हुए चरित्र छुपाएं - Hide Live Paint Gaps छुपा लाइव पेंट गैप + Hide Hidden Characters हिडन कैरेक्टर छिपाएँ + Hide Live Paint Gaps लाइव पेंट गैप छिपाएँ Hide Note नोट छिपाएँ Hide Options ऑप्शन्स छिपाएँ Hide Others अन्य छिपाएँ @@ -2234,23 +2235,23 @@ antiAlias Sharp शार्प Hide Template टेम्पलेट छिपाएँ Hide Text Threads टेक्स्ट थ्रेड छुपाएं Hide Thumbnail थंबनेल छिपाएँ - Hide Thumbnails हिंदुस्तान + Hide Thumbnails थंबनेल छिपाएँ Hide Transparency Grid पारदर्शिता ग्रिड छिपाएँ Hide Units इकाइयाँ छिपाएँ Hide VectorCraft VectorCraft छिपाएँ - Hide Video Rulers वीडियो शासक + Hide Video Rulers वीडियो रूलर्स छिपाएँ High उच्च High (300 ppi) उच्च (300 ppi) - High Contrast उच्च न्यायालय - High Contrast 2 उच्च न्यायालय 2 - High Contrast 3 उच्च न्यायालय 3 + High Contrast उच्च कंट्रास्ट + High Contrast 2 उच्च कंट्रास्ट 2 + High Contrast 3 उच्च कंट्रास्ट 3 High Fidelity Photo उच्च निष्ठा फोटो High Performance (discrete) उच्च निष्पादन (डिस्क्रीट) High Quality Print उच्च गुणवत्ता वाले प्रिंट High resolution उच्च रिज़ॉल्यूशन High Resolution उच्च रिज़ॉल्यूशन Highlight हाइलाइट - Highlight Alternate Glyphs हाइलाइट अल्टरनेटर ग्लिफ़ + Highlight Alternate Glyphs अल्टरनेट ग्लिफ़ हाइलाइट करें Highlight anchors on mouse over माउस ओवर पर हाइलाइट एंकर Highlight Area हाइलाइट क्षेत्र Highlight Font Name हाइलाइट फ़ॉन्ट नाम @@ -2475,7 +2476,7 @@ antiAlias Sharp शार्प Keep Original मूल रखें Keep Plain Text प्लेन टेक्स्ट रखें Keep selected सिलेक्ट करें - Keep Shared Points Together साझा अंक एक साथ रखें + Keep Shared Points Together साझा पॉइंट्स एक साथ रखें Keep the pasted color as a new swatch with a numbered name. पेस्ट किए गए रंग को क्रमांकित नाम वाले नए स्वॉच के रूप में रखें। Keep this row's colors (exclude) इस पंक्ति के रंग (बाहर) रखें Keep visible surfaces only when expanding विस्तार करते समय केवल दृश्यमान सतहों को रखें @@ -2762,7 +2763,7 @@ antiAlias Sharp शार्प Manage Workspaces… वर्कस्पेस प्रबंधित करें… Manually मैन्युअल Marathi मराठी - Mark as trapped निशानेबाज़ी + Mark as trapped ट्रैप्ड के रूप में चिह्नित करें Mark Older Files as [Converted] When Opened पुरानी फ़ाइलें ओपन करने पर उन्हें [Converted] चिह्नित करें Marker… मार्कर… Marks मार्क्स @@ -2833,7 +2834,7 @@ antiAlias Sharp शार्प Minimize छोटा करें Minimum न्यूनतम Minimum angle न्यूनतम कोण - Minimum must not exceed Desired, nor Desired Maximum. न्यूनतम से अधिक नहीं होना चाहिए, न ही अधिकतम होना चाहिए। + Minimum must not exceed Desired, nor Desired Maximum. न्यूनतम, वांछित से अधिक नहीं होना चाहिए और वांछित, अधिकतम से अधिक नहीं होना चाहिए। Minimum: न्यूनतम: Minus Back पीछे वाला घटाएँ Minus Front सामने वाला घटाएँ @@ -3037,7 +3038,7 @@ antiAlias Sharp शार्प Next Segment अगला खंड No नहीं No actions. Press ● to record. कोई कार्रवाई नहीं। प्रेस ● रिकॉर्ड करने के लिए। - No alternates for the selected character. सिलेक्टेड चरित्र के लिए कोई ऑप्शन नहीं। + No alternates for the selected character. सिलेक्टेड कैरेक्टर के लिए कोई अल्टरनेट नहीं। No artboards match. कोई आर्टबोर्ड मैच नहीं। No assets कोई संपत्ति नहीं No assets yet. Collect art with Object › Collect for Export, or drag it into the Asset Export panel. अभी कोई एसेट नहीं है। ऑब्जेक्ट › एक्सपोर्ट के लिए कलेक्ट करें से आर्टवर्क जोड़ें या उसे एसेट एक्सपोर्ट पैनल में ड्रैग करें। @@ -3120,7 +3121,7 @@ antiAlias Sharp शार्प Number of Recent Fonts हाल ही में फ़ॉन्ट की संख्या Number: संख्या: Numbered List क्रमांकित सूची - Numbers Without Units Are Points यूनिट्स के बिना नंबर अंक हैं + Numbers Without Units Are Points यूनिट्स के बिना संख्याएँ पॉइंट में हैं Numerator न्यूमेरेटर Object ऑब्जेक्ट Object Highlighting ऑब्जेक्ट हाइलाइटिंग @@ -3294,7 +3295,7 @@ antiAlias Sharp शार्प Outer Glow… बाहरी ग्लो… Outline रूपरेखा Outline Object रूपरेखा ऑब्जेक्ट - Outline Others अन्य लोग + Outline Others अन्य को आउटलाइन व्यू में दिखाएँ Outline Stroke स्ट्रोक को आउटलाइन में बदलें Outline Text टेक्स्ट को आउटलाइन में बदलें Outlined Strokes रेखांकित स्ट्रोक @@ -3348,7 +3349,7 @@ antiAlias Sharp शार्प Pages पृष्ठ Paint Daubs पेंट डॉब्स Paint Daubs… पेंट डॉब्स… - Paint Fills पेंट भरता + Paint Fills फ़िल्स पेंट करें Paint Opacity: पेंट ओपेसिटी: Paint Stops At: पेंट स्टॉप: Paint Strokes पेंट स्ट्रोक @@ -3581,7 +3582,7 @@ antiAlias Sharp शार्प Pointillize पॉइंटिलाइज़ Pointillize… पॉइंटिलाइज़… Points पॉइंट्स - Points per segment प्रति खंड अंक + Points per segment प्रति सेगमेंट पॉइंट्स Points: पॉइंट्स: Points: clicks add free points पॉइंट्स: क्लिक से अलग-अलग पॉइंट जुड़ते हैं Polar Grid ध्रुवीय ग्रिड @@ -3701,6 +3702,8 @@ antiAlias Sharp शार्प Properties प्रॉपर्टीज़ Proportional Lining आनुपातिक लाइनिंग Proportional Metrics आनुपातिक मीट्रिक + H or V Style क्षैतिज या ऊर्ध्वाधर शैली + Roman Italics रोमन इटैलिक Insert Aki Left बाईं ओर आकी जोड़ें Insert Aki Right दाईं ओर आकी जोड़ें Tsume त्सुमे @@ -3920,7 +3923,7 @@ antiAlias Sharp शार्प Remove Line Breaks लाइन ब्रेक हटाएँ Remove Paragraph Breaks पैराग्राफ ब्रेक निकालें Remove Plug-in प्लग-इन निकालें - Remove Points, Keep Shape अंक निकालें, आकार रखें + Remove Points, Keep Shape पॉइंट्स हटाएँ, शेप रखें Remove Redundant Points गैरज़रूरी पॉइंट हटाएँ Remove Selected Assets सिलेक्टेड परिसंपत्तियों को हटा दें Remove Selected Entry सिलेक्टेड प्रविष्टि निकालें @@ -4025,7 +4028,7 @@ antiAlias Sharp शार्प Edit Arrowheads… एरोहेड्स संपादित करें… Reload Arrowheads एरोहेड्स फिर से लोड करें Reset Rotate View व्यू घुमाएँ रीसेट करें - Reset Ruler Origin शासकीय उत्पत्ति रीसेट करें + Reset Ruler Origin रूलर ओरिजिन रीसेट करें Reset Symbol Instance सिंबल इंस्टेंस रीसेट करें Reset Text Transform टेक्स्ट ट्रांसफॉर्मर को रीसेट करें Reset Text Transform and Scale टेक्स्ट ट्रांसफॉर्म और स्केल को रीसेट करें @@ -4099,7 +4102,7 @@ antiAlias Sharp शार्प RGB: RGB: Ridges per segment: प्रति खंड रिज: Right दाएँ - Right Complement सही पूरक + Right Complement राइट कम्प्लीमेंट Right Diagonal दायाँ विकर्ण Right Grid: राइट ग्रिड: Right in Front फ्रंट में राइट @@ -4158,14 +4161,14 @@ antiAlias Sharp शार्प Ruby रूबी Ruby… रूबी… Rule: नियम: - Ruler Origin शासकीय उत्पत्ति + Ruler Origin रूलर ओरिजिन Rulers रूलर्स Rulers & Grids रूलर्स और ग्रिड Run Length रन लंबाई Run Plug-in रन प्लग-इन Runs code the checker can't read रन कोड चेकर को पढ़ा नहीं जा सकता Russian रूसी - Same समीर + Same समान Same As Source स्रोत के समान Same file एक ही फ़ाइल Same item as a later row बाद में पंक्ति के रूप में समान आइटम @@ -4308,8 +4311,8 @@ antiAlias Sharp शार्प Saved Views सेव किया गया दृश्य Saves the current bars, toolbar and panel layout. वर्तमान बार, टूलबार और पैनल लेआउट को सेव करता है। Saving सेव किया जा रहा है - Saving {} बचत {} - Saving Poster.vectorcraft बचत Poster.vectorcraft + Saving {} {} सेव किया जा रहा है + Saving Poster.vectorcraft Poster.vectorcraft सेव किया जा रहा है Saving recovery data for {} {} के लिए रिकवरी डेटा सेव करना Scale आकार बदलें Scale Artwork with Artboard आर्टबोर्ड के साथ स्केल आर्टवर्क @@ -4373,7 +4376,7 @@ antiAlias Sharp शार्प Select a character with the Type tool to see its alternates. अल्टरनेट ग्लिफ़ देखने के लिए टाइप टूल से कोई कैरेक्टर सिलेक्ट करें। Select a parametric shape to edit it. With none selected, these set the next shape. एडिट करने के लिए पैरामीट्रिक शेप सिलेक्ट करें। कोई शेप सिलेक्ट न होने पर ये सेटिंग्स अगली शेप पर लागू होंगी। Select a text object एक टेक्स्ट ऑब्जेक्ट चुनें - Select a text object to edit its character attributes. अपने चरित्र विशेषताओं को संपादित करने के लिए एक टेक्स्ट ऑब्जेक्ट चुनें। + Select a text object to edit its character attributes. कैरेक्टर एट्रिब्यूट्स संपादित करने के लिए एक टेक्स्ट ऑब्जेक्ट चुनें। Select a text object to edit its paragraph attributes. अपनी पैराग्राफ विशेषताओं को संपादित करने के लिए एक टेक्स्ट ऑब्जेक्ट चुनें। Select a text object to set its tab stops. अपने टैब स्टॉप सेट करने के लिए एक टेक्स्ट ऑब्जेक्ट चुनें। Select All सभी सिलेक्ट करें @@ -4402,7 +4405,7 @@ antiAlias Sharp शार्प Select by Link Status… लिंक स्थिति द्वारा सिलेक्ट करें… Select Data Set डेटा सेट सिलेक्ट करें Select Embedded एम्बेडेड सिलेक्ट करें - Select Fills भरता सिलेक्ट करें + Select Fills फ़िल्स सिलेक्ट करें Select Freeform Point फ़्रीफ़ॉर्म पॉइंट सिलेक्ट करें Select Gradient Stop ग्रेडिएंट स्टॉप चुनें Select Guides गाइड्स सिलेक्ट करें @@ -4649,7 +4652,7 @@ antiAlias Sharp शार्प Show Tool Tips टूल युक्तियाँ दिखाएँ Show Toolbar शो टूलबार Show Transparency Grid पारदर्शिता ग्रिड दिखाएँ - Show Video Rulers शो वीडियो शासक + Show Video Rulers वीडियो रूलर्स दिखाएँ Show Video Safe Areas वीडियो सुरक्षित क्षेत्र दिखाएं Show Vivid/Muted चमकीले/मंद दिखाएँ Show Warm/Cool शो वार्म / कूल @@ -5115,7 +5118,7 @@ antiAlias Sharp शार्प The Print Tiling tool placed the pages. प्रिंट टाइलिंग टूल ने पृष्ठों को रखा। The samples show this brush on a blue stroke. नमूने इस ब्रश को ब्लू स्ट्रोक पर दिखाते हैं। The search stopped at its limit: search a smaller folder. सर्च अपनी लिमिट तक पहुँच गई: छोटे फ़ोल्डर में सर्च करें। - The selected character's font is not available. सिलेक्टेड चरित्र का फ़ॉन्ट उपलब्ध नहीं है। + The selected character's font is not available. सिलेक्टेड कैरेक्टर का फ़ॉन्ट उपलब्ध नहीं है। The selected objects have no CSS of their own (see below). सिलेक्टेड ऑब्जेक्ट का अपना CSS नहीं है (नीचे देखें)। The system's settings of this printer इस प्रिंटर की प्रणाली की सेटिंग These fonts aren't available. Their text shows in another font. ये फ़ॉन्ट उपलब्ध नहीं हैं। इनका टेक्स्ट दूसरे फ़ॉन्ट में दिखता है। @@ -5149,7 +5152,7 @@ antiAlias Sharp शार्प Tile Edge Color टाइल एज रंग Tile Edge Color… टाइल एज रंग… Tile Full Pages टाइल पूर्ण पृष्ठ - Tile Imageable Areas टाइल कल्पनाशील क्षेत्र + Tile Imageable Areas इमेजेबल क्षेत्र टाइल करें Tile Range: टाइल रेंज: Tile Size टाइल साइज़ Tile Type: टाइल प्रकार: @@ -5258,9 +5261,9 @@ antiAlias Sharp शार्प Transverse (the page a quarter turn on the paper) ट्रांसवर्स (पेपर पर एक चौथाई मोड़ पृष्ठ) Trap ट्रैप Trap… ट्रैप… - Traps with Process Color प्रक्रिया रंग के साथ जाल - Triad त्रिदाद - Triad 2 त्रिद 2 + Traps with Process Color प्रोसेस रंग के साथ ट्रैप + Triad त्रिक + Triad 2 त्रिक 2 Triad 3 त्रिक 3 Triangle त्रिभुज Triangle (open) त्रिभुज (ओपन) @@ -5268,7 +5271,7 @@ antiAlias Sharp शार्प Trim ट्रिम करें Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. अपीयरेंस एक्सपैंड करने पर ढकी हुई ज्यामिति ट्रिम करें। लाइव प्रीव्यू पूरी सतह रखता है। ट्रांसपेरेंट पेंट और विज़िबिलिटी लिमिट से बड़ी सतहों के सभी फेस बने रहते हैं। Trim mark weight: ट्रिम मार्क वजन: - Trim marks ट्रिम अंक + Trim marks ट्रिम मार्क्स Trim View ट्रिम देखें True Colors असली रंग True colour needs version 2004 or later: the nearest of 256 colours is written. ट्रू कलर के लिए संस्करण 2004 या बाद का चाहिए: अभी 256 रंगों में सबसे निकट वाला रंग लिखा जाता है। @@ -5495,7 +5498,7 @@ antiAlias Sharp शार्प Video 1920×1080 1920 × 1080 वीडियो Video 3840×2160 वीडियो 3840 × 2160 Video Banner 2560×1440 वीडियो बैनर 2560 × 1440 - Video Ruler Pixel Aspect Ratio: वीडियो शासक पिक्सेल दृष्टि अनुपात: + Video Ruler Pixel Aspect Ratio: वीडियो रूलर पिक्सेल आस्पेक्ट रेशियो: Fade Region Outside Artboard आर्टबोर्ड के बाहर का क्षेत्र धुंधला करें Update While Dragging खींचते समय अपडेट करें Video Thumbnail 1280×720 वीडियो थंबनेल 1280 × 720 @@ -5520,7 +5523,7 @@ antiAlias Sharp शार्प Warp Options वार्प ऑप्शन्स Warp Tool वार्प टूल Watch 184×224 घड़ी 184 × 224 - Watch 198×242 देखें 198 × 242 + Watch 198×242 घड़ी 198 × 242 Water Paper वॉटर पेपर Water Paper… वॉटर पेपर… Watercolor वॉटरकलर @@ -6017,7 +6020,7 @@ antiAlias Sharp शार्प @msg `{name}` is no recovery entry name `{name}` कोई वसूली प्रविष्टि नाम नहीं है @msg `{name}` isn't a colour swatch `{name}` रंग स्वॉच नहीं है @msg `{name}` takes a paint of its own kind (colour, gradient or pattern) `{name}` अपने ही प्रकार का पेंट लेता है (रंग, ग्रेडिएंट या पैटर्न) -@msg `{name}`: Minimum must not exceed Desired, nor Desired Maximum `{name}`: न्यूनतम इच्छा से अधिक नहीं होना चाहिए, न ही इच्छा अधिकतम +@msg `{name}`: Minimum must not exceed Desired, nor Desired Maximum `{name}`: न्यूनतम, वांछित से अधिक नहीं होना चाहिए और वांछित, अधिकतम से अधिक नहीं होना चाहिए @msg `{other}` doesn't trace an image `{other}` एक इमेज का पता नहीं लगा @msg `{path}` must be a bool or an object of bools `{path}` एक bool होना चाहिए या एक ऑब्जेक्ट होना चाहिए। @msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` एक रंग नहीं है (#rrggbb या एक पूर्व निर्धारित नाम) @@ -6372,7 +6375,7 @@ antiAlias Sharp शार्प @msg a wraparound group is inside a compound path एक रैपराउंड समूह एक मिश्रित पाथ के अंदर है @msg action is required action आवश्यक है @msg add a format to export निर्यात करने के लिए एक फ़ॉर्मैट जोड़ें -@msg Add Points at Crossings: a path went missing क्रॉसिंग्स में अंक जोड़ें: एक पाथ गायब हो गया +@msg Add Points at Crossings: a path went missing क्रॉसिंग पर पॉइंट जोड़ें: एक पाथ गायब हो गया @msg aiVersion {v}: "pdf" (PDF-compatible, the default) or "7" (legacy .ai, version 7) aiVersion {v}: "pdf" (PDF-संगत, डिफ़ॉल्ट) या "7" (पुरानी .ai, संस्करण 7) @msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 विरासत .ai फ़ाइलों को लिखते हैं, .ait टेम्पलेट नहीं @msg All colors are within the CMYK gamut सभी रंग CMYK गामट के भीतर हैं @@ -6560,7 +6563,7 @@ antiAlias Sharp शार्प @msg choose at least one status कम से कम एक स्थिति चुनें @msg Choose original artwork, not a dimension's generated paths मूल आर्टवर्क चुनें, एक आयाम उत्पन्न पाथ नहीं @msg choose the font files to add जोड़ने के लिए फ़ॉन्ट फ़ाइलों को चुनें -@msg Choose two different points दो अलग-अलग अंक चुनें +@msg Choose two different points दो अलग-अलग पॉइंट्स चुनें @msg Choose two non-parallel straight segments दो गैर-parallel सीधे खंड चुनें @msg Choose two nonzero angle arms दो nonzero कोण हथियार चुनें @msg Click in type with a Type tool to split it इसे विभाजित करने के लिए टाइप टूल के साथ टाइप करें @@ -6710,6 +6713,7 @@ antiAlias Sharp शार्प @msg there is no script engine named {name} {name} नाम का कोई स्क्रिप्ट इंजन नहीं है @msg Running a script स्क्रिप्ट चल रही है @msg Copied the script report Copied स्क्रिप्ट रिपोर्ट +@msg Copied the link info लिंक जानकारी कॉपी की गई @msg copy needs path: the file to copy into the document's folder copy के लिए path चाहिए: डॉक्यूमेंट के फ़ोल्डर में कॉपी करने वाली फ़ाइल @msg copy takes images only: relink placed documents where their files are copy केवल इमेज लेता है: प्लेस किए गए डॉक्यूमेंट्स को वहीं फिर से लिंक करें जहाँ उनकी फ़ाइलें हैं @msg copying a file isn't available on the web एक फ़ाइल की प्रतिलिपि वेब पर उपलब्ध नहीं है @@ -6777,7 +6781,7 @@ antiAlias Sharp शार्प @msg Dimension mode does not match its source आयाम मोड अपने स्रोत से मेल नहीं खाता है @msg Dimension point is outside the canvas आयाम बिंदु कैनवास के बाहर है @msg Dimension size is outside its supported range आयाम आकार अपनी समर्थित सीमा से बाहर है -@msg direct-select anchor points of a path एक पाथ के प्रत्यक्ष सिलेक्शन एंकर अंक +@msg direct-select anchor points of a path किसी पाथ के एंकर पॉइंट्स डायरेक्ट-सिलेक्ट करें @msg direct-select anchor points to cut at (not the end points of open paths) डायरेक्ट-सिलेक्शन एंकर पॉइंट्स को कट करने के लिए ( ओपन पाथ के अंतिम बिंदु नहीं) @msg direct-select the anchor points to remove हटाने के लिए एंकर पॉइंट्स सिलेक्ट करें @msg distance must be > 0 distance > 0 होना चाहिए @@ -6832,7 +6836,7 @@ antiAlias Sharp शार्प @msg exit isolation mode first पहले अलगाव मोड से बाहर निकलें @msg Expand Appearance: the selection has a basic appearance उपस्थिति का विस्तार: सिलेक्शन में एक बुनियादी उपस्थिति है @msg Expand: no brush strokes selected विस्तार: सिलेक्टेड कोई ब्रश स्ट्रोक नहीं -@msg Expand: no gradient fills विस्तार: कोई ग्रेडिएंट भरता नहीं +@msg Expand: no gradient fills एक्सपैंड: कोई ग्रेडिएंट फ़िल नहीं @msg Expand: nothing to expand विस्तार: विस्तार करने के लिए कुछ नहीं @msg expanded SVG styles exceed the import size limit विस्तारित SVG स्टाइल्स आयात आकार सीमा से अधिक है @msg expected {{"dataBase64": "…"}} अपेक्षित: {{"dataBase64": "…"}} @@ -6965,7 +6969,7 @@ antiAlias Sharp शार्प @msg Global Edit is off ग्लोबल एडिट बंद है @msg Global Edit works on paths, compound paths, symbol instances and groups of them ग्लोबल एडिट पाथ, मिश्रित पाथ, सिंबल उदाहरणों और उनके समूहों पर काम करता है @msg global must be true or false global को true या false होना चाहिए -@msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) Glyph {_1} चरित्र की स्टाइल (जैसे सभी कैप्स, छोटे कैप्स या एक संख्यात्मक प्रणाली) के साथ {ch} के लिए तैयार नहीं है। +@msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) Glyph {_1} कैरेक्टर की स्टाइल (जैसे सभी कैप्स, छोटे कैप्स या एक संख्यात्मक प्रणाली) के साथ {ch} के लिए तैयार नहीं है। @msg glyph {g} is not an alternate of {ch} in {_1} {_2} Glyph {g} {_1} {_2} में {ch} का एक वैकल्पिक नहीं है। @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it ग्रेडिएंट "{name}" को फ़ाइल में परिभाषित नहीं किया गया है: इसके लिए एक सादे रंग खड़ा है @msg gradient must be "objects" or "mesh" gradient "objects" या "mesh" होना चाहिए @@ -7222,8 +7226,8 @@ antiAlias Sharp शार्प @msg missing point लापता बिंदु @msg missing point [x,y] लापता बिंदु [x,y] @msg missing point `{key}` ([x, y]) लापता बिंदु `{key}` ([x, y]) -@msg missing points लापता अंक -@msg missing points [[x,y]…] लापता अंक [[x,y]...] +@msg missing points पॉइंट्स नहीं दिए गए +@msg missing points [[x,y]…] पॉइंट्स नहीं दिए गए [[x,y]…] @msg missing segment लापता खंड @msg missing stops लापता ठहराव @msg missing subpaths लापता subpaths @@ -7270,7 +7274,7 @@ antiAlias Sharp शार्प @msg no active data set कोई सक्रिय डेटा सेट नहीं @msg no active document कोई सक्रिय डॉक्यूमेंट @msg no active perspective plane कोई सक्रिय पर्सपेक्टिव विमान नहीं -@msg no anchor points selected कोई एंकर अंक नहीं चुना गया +@msg no anchor points selected कोई एंकर पॉइंट सिलेक्ट नहीं है @msg no app to open files here यहां फ़ाइलों को ओपन करने के लिए कोई ऐप नहीं है @msg no appearance item {index} कोई उपस्थिति आइटम {index} @msg no artboard कोई आर्टबोर्ड नहीं @@ -7295,7 +7299,7 @@ antiAlias Sharp शार्प @msg no brush selected कोई ब्रश नहीं चुना गया @msg no catalog कोई सूची नहीं @msg no changes since the last save आखिरी सेव के बाद से कोई बदलाव नहीं -@msg no character at byte {start} byte {start} पर कोई चरित्र नहीं +@msg no character at byte {start} byte {start} पर कोई कैरेक्टर नहीं @msg no character style `{name}` कोई कैरेक्टर स्टाइल `{name}` @msg no clock to keep a heartbeat by हार्टबीट का समय रखने के लिए कोई घड़ी नहीं @msg no color group `{name}` in the document (see swatch.list) डॉक्यूमेंट में कोई रंग समूह `{name}` (देखें swatch.list) @@ -7799,7 +7803,7 @@ antiAlias Sharp शार्प @msg Saved to User Defined: {_1} उपयोगकर्ता परिभाषित करने के लिए सेव किया गया: {_1} @msg savedPreset: {e} savedPreset: {e} @msg Saving सेव किया जा रहा है -@msg Saving {_1} बचत {_1} +@msg Saving {_1} {_1} सेव किया जा रहा है @msg Saving recovery data for {_1} {_1} के लिए रिकवरी डेटा सेव करना @msg scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi) पैमाने `{s}`: एक कारक जैसे 2x, चौड़ाई (100w), ऊंचाई (100h) या एक रिज़ॉल्यूशन (72ppi) @msg scale must be a number above 0, at most 64 scale 0 से बड़ी और अधिकतम 64 संख्या होनी चाहिए @@ -7891,7 +7895,7 @@ antiAlias Sharp शार्प @msg Select only dimensions केवल आयाम सिलेक्ट करें @msg select open paths to close बंद करने के लिए खुले रास्ते सिलेक्ट करें @msg select paths पाथ चुनें -@msg select paths or anchor points पाथ या एंकर अंक चुनें +@msg select paths or anchor points पाथ या एंकर पॉइंट्स चुनें @msg select paths or compound paths पाथ या यौगिक पाथ सिलेक्ट करें @msg select paths to join जोड़ने के लिए पाथ सिलेक्ट करें @msg select paths with dashed strokes to expand विस्तृत करने के लिए डैश स्ट्रोक के साथ पाथ सिलेक्ट करें @@ -7899,7 +7903,7 @@ antiAlias Sharp शार्प @msg select plain rectangles, ellipses or regular polygons to convert बदलने के लिए सादे आयत, अंडाकार या नियमित बहुभुजों सिलेक्ट करें @msg Select point type पॉइंट टाइप सिलेक्ट करें @msg select rows or objects to collect इकट्ठा करने के लिए पंक्तियों या ऑब्जेक्ट्स सिलेक्ट करें -@msg select segments, or paths, to add points to खंडों, या पाथ का सिलेक्ट करने के लिए, अंक जोड़ने के लिए +@msg select segments, or paths, to add points to पॉइंट्स जोड़ने के लिए सेगमेंट या पाथ सिलेक्ट करें @msg select some type first पहले कुछ टाइप सिलेक्ट करें @msg select something to export निर्यात करने के लिए कुछ का सिलेक्शन @msg select something to export (or turn off selectedOnly) एक्सपोर्ट करने के लिए कुछ चुनें (या selectedOnly बंद करें) @@ -8085,7 +8089,7 @@ antiAlias Sharp शार्प @msg the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size आर्टबोर्ड कैनवास पर फिट नहीं हैं: कम आर्टबोर्ड, कम रिक्ति या छोटे आकार @msg the background worker stopped पृष्ठभूमि कार्यकर्ता बंद कर दिया @msg the blend has no spine मिश्रण में कोई रीढ़ नहीं है -@msg the character's size can't be measured चरित्र का आकार मापा नहीं जा सकता है +@msg the character's size can't be measured कैरेक्टर का आकार मापा नहीं जा सकता है @msg the CMYK image couldn't be written CMYK इमेज को नहीं लिखा जा सकता @msg the Color Picker is not open रंग पिकर खुला नहीं है @msg the compound shape is too complex to expand मिश्रित आकार का विस्तार करने के लिए बहुत जटिल है @@ -8138,7 +8142,7 @@ antiAlias Sharp शार्प @msg the file's variable library couldn't be read: the Variables panel doesn't list it फ़ाइल की वेरिएबल लाइब्रेरी पढ़ी नहीं जा सकी: वेरिएबल्स पैनल उसे सूचीबद्ध नहीं करता @msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest फ़ाइल की वेरिएबल लाइब्रेरी आंशिक रूप से पढ़ी गई (छोड़ी गई प्रविष्टियाँ: {_1}): वेरिएबल्स पैनल शेष प्रविष्टियाँ दिखाता है @msg the files come to more than 1 GB: add fewer at a time फ़ाइलों को 1 GB से अधिक करने के लिए आते हैं: एक समय में कम जोड़ें -@msg the first character of a line has no character before it to kern: it moves only up and down (dy) एक पंक्ति के पहले चरित्र में बर्न से पहले कोई चरित्र नहीं है: यह केवल ऊपर और नीचे चला जाता है (डी) +@msg the first character of a line has no character before it to kern: it moves only up and down (dy) किसी लाइन के पहले कैरेक्टर से पहले कर्न करने के लिए कोई कैरेक्टर नहीं है: यह केवल ऊपर और नीचे जाता है (dy) @msg the font {_1} is not available फ़ॉन्ट {_1} उपलब्ध नहीं है @msg the format has no file extension फ़ॉर्मैट में कोई फ़ाइल एक्सटेंशन नहीं है @msg the graphic can't be read: {e} ग्राफिक पढ़ा नहीं जा सकता: {e} @@ -8428,8 +8432,8 @@ antiAlias Sharp शार्प @msg unknown brush type `{o}` अज्ञात ब्रश प्रकार `{o}` @msg unknown category `{c}` अज्ञात श्रेणी `{c}` @msg unknown category `{c}` ({_1}) अज्ञात श्रेणी `{c}` ({_1}) -@msg unknown character `{c}` अज्ञात चरित्र `{c}` -@msg unknown character style number field अज्ञात चरित्र स्टाइल संख्या क्षेत्र +@msg unknown character `{c}` अज्ञात कैरेक्टर `{c}` +@msg unknown character style number field अज्ञात कैरेक्टर स्टाइल संख्या फ़ील्ड @msg unknown CMYK profile `{name}` अज्ञात CMYK प्रोफ़ाइल `{name}` @msg unknown command `{0}` अज्ञात आदेश `{0}` @msg step {step} of “{action}” runs the unknown command `{command}` “{action}” का चरण {step} अज्ञात आदेश `{command}` चलाता है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index dfaf20651f..ef3b1b4702 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -838,6 +838,7 @@ font-technology Variable Változó Copy Artboard Rajztábla másolása Copy Color Value (Hex) Színérték másolása (hex) Copy Fonts Used in the Document A dokumentumban használt betűtípusok másolása + Copy Info Adatok másolása Copy Links Csatolások másolása Copy only fonts you own or are licensed to install. Csak olyan betűtípust másolj, amely a tiéd, vagy amelynek telepítésére licenced van. Copy Selected Style Kijelölt stílus másolása @@ -3227,6 +3228,8 @@ mark-style Japanese Japán Proof Setup Színpróba beállítása Properties Tulajdonságok Proportional Metrics Arányos metrika + H or V Style Vízszintes vagy függőleges stílus + Roman Italics Latin betűk dőlt változata Insert Aki Left Aki beszúrása balra Insert Aki Right Aki beszúrása jobbra Tsume Tsume @@ -5205,6 +5208,7 @@ imageTrace Outline Körvonal @msg there is no script engine named {name} nincs {name} nevű parancsfájlmotor @msg Running a script Parancsfájl futtatása @msg Copied the script report Szkriptjelentés másolva +@msg Copied the link info Csatolás adatai másolva @msg Corners edits the corners of a path a Sarkok parancs egy útvonal sarkait szerkeszti @msg could not decode image: {0} a kép nem dekódolható: {0} @msg Couldn't copy to the system clipboard: {e} Nem sikerült a rendszer vágólapjára másolni: {e} @@ -9126,7 +9130,7 @@ breakText Glyphs Glifák @msg unexpected resources váratlan erőforrások @msg unknown CMYK profile `{name}` ismeretlen CMYK-profil: `{name}` @msg unknown File Info field `{k}` ismeretlen fájlinformációs mező: `{k}` -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) ismeretlen OpenType-funkció: `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; a - előtag kikapcsolja; a swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom és aalt egy alternatíva számát `tag=N` alakban fogadja, ahol N 0 és {_1} között lehet, például `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) ismeretlen OpenType-funkció: `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; a - előtag kikapcsolja; a swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom és aalt egy alternatíva számát `tag=N` alakban fogadja, ahol N 0 és {_1} között lehet, például `salt=2`) @msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) ismeretlen OpenType-funkció a featureChanges értékben: `{t}` (a `features` által fogadott címkék használhatók) @msg unknown PDF preset `{name}` (presets: {_1}) ismeretlen PDF-előbeállítás: `{name}` (előbeállítások: {_1}) @msg unknown Warp style ismeretlen hajlítási stílus diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index ec25754814..dc1930ea0a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1520,6 +1520,8 @@ lighting Light Cahaya Leading Leading No Break Tanpa pemenggalan Proportional Metrics Metrik proporsional + H or V Style Gaya horizontal atau vertikal + Roman Italics Miring Romawi Insert Aki Left Sisipkan aki kiri Insert Aki Right Sisipkan aki kanan Tsume Tsume @@ -2517,6 +2519,7 @@ digits Malayalam Malayalam Transparent Background Latar belakang transparan Choose… Pilih… Copy Fonts Used in the Document Salin font yang digunakan dalam dokumen + Copy Info Salin info Copy Links Salin tautan Create Report Buat laporan Fonts whose licence doesn't allow embedding are not copied. Font yang lisensinya tidak mengizinkan penyematan tidak disalin. @@ -4299,6 +4302,7 @@ imageTrace Outline Kontur Stop the script and undo what it did? Then quit again. Hentikan skrip dan batalkan yang telah dilakukannya? Lalu keluar lagi. This script has added a great many objects. Continue, or stop it and undo what it did? Skrip ini telah menambahkan sangat banyak objek. Lanjutkan, atau hentikan dan batalkan yang telah dilakukannya? @msg Copied the script report Berhasil menyalin laporan skrip +@msg Copied the link info Berhasil menyalin info tautan @msg Corners edits the corners of a path Corners menyunting sudut jalur @msg Couldn't copy to the system clipboard: {e} Tidak dapat menyalin ke papan klip sistem: {e} @msg Couldn't open {name}: {e} Tidak dapat membuka {name}: {e} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3ed0ea8611..88638286d3 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -646,6 +646,7 @@ font-technology Variable Variabili Copy All Styles Copia tutti gli stili Copy Color Value (Hex) Copia valore colore (hex) Copy Fonts Used in the Document Copia i font usati nel documento + Copy Info Copia informazioni Copy Links Copia collegamenti Copy Selected Style Copia stile selezionato Copy to User Defined Copia in Definito dall'utente @@ -2712,6 +2713,8 @@ mark-style Japanese Giapponese Proof Setup Impostazione prova Properties Proprietà Proportional Metrics Metrica proporzionale + H or V Style Stile orizzontale o verticale + Roman Italics Corsivo romano Insert Aki Left Inserisci aki a sinistra Insert Aki Right Inserisci aki a destra Tsume Tsume @@ -4445,6 +4448,7 @@ axis Both Entrambi @msg there is no script engine named {name} non esiste un motore di script chiamato {name} @msg Running a script Esecuzione di uno script @msg Copied the script report Rapporto dello script copiato +@msg Copied the link info Informazioni collegamento copiate @msg Corners edits the corners of a path Angoli modifica gli angoli di un tracciato @msg Couldn't copy to the system clipboard: {e} Impossibile copiare negli appunti di sistema: {e} @msg Couldn't open {name}: {e} Impossibile aprire {name}: {e} @@ -7524,7 +7528,7 @@ breakText Glyphs Glifi @msg toString is not a function toString non è una funzione @msg too many arguments troppi argomenti @msg too many quantifiers troppi quantificatori -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) funzione OpenType sconosciuta `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; anteponi - per disattivarla; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom e aalt accettano il numero di un'alternativa come `tag=N` con N da 0 a {_1}, ad esempio `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) funzione OpenType sconosciuta `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; anteponi - per disattivarla; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom e aalt accettano il numero di un'alternativa come `tag=N` con N da 0 a {_1}, ad esempio `salt=2`) @msg unknown dither `{d}` (diffusion, pattern, noise or none) dithering sconosciuto `{d}` (diffusion, pattern, noise o none) @msg unknown panel `{panel}` pannello sconosciuto `{panel}` @msg unknown parameter `{key}` parametro sconosciuto `{key}` diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 221c59098f..cba830841b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -50,6 +50,8 @@ font-technology Variable バリアブル x-Height x ハイト ICF Box 平均字面 Proportional Metrics プロポーショナルメトリクス + H or V Style 横組み・縦組み用かな + Roman Italics 欧文イタリック Insert Aki Left 前にアキを挿入 Insert Aki Right 後ろにアキを挿入 Tsume 文字ツメ @@ -3775,6 +3777,7 @@ gradient-kind Radial 円形 Convert To Outlines アウトラインに変換 Copy All Objects すべてのオブジェクトをコピー Copy Fonts Used in the Document ドキュメントで使用しているフォントをコピー + Copy Info 情報をコピー Copy Links リンクをコピー Couldn't copy to the system clipboard: {e} システムのクリップボードにコピーできませんでした:{e} Couldn't paste from the clipboard: {e} クリップボードからペーストできませんでした:{e} @@ -5210,6 +5213,7 @@ swatch Bright Violet ビビッドバイオレット @msg there is no script engine named {name} {name} という名前のスクリプトエンジンはありません @msg Running a script スクリプトを実行中 @msg Copied the script report スクリプトのレポートをコピーしました +@msg Copied the link info リンク情報をコピーしました @msg Exported {path} {path} を書き出しました @msg no CSS to copy: select objects with CSS of their own コピーする CSS がありません: 独自の CSS を持つオブジェクトを選択してください @msg unknown highlight `{h}` `{h}` は不明なハイライトの指定です @@ -6731,7 +6735,7 @@ axis Y Axis Y 軸 @msg toString is not a function toString は関数ではありません @msg too many arguments 引数が多すぎます @msg too many quantifiers 量指定子が多すぎます -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) `{t}` は不明な OpenType 機能です (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt。オフにするには先頭に - を付けます。swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt には、`salt=2` のように `tag=N` の形式で代替字形の番号を指定できます。N は 0 から {_1} まで) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) `{t}` は不明な OpenType 機能です (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital。オフにするには先頭に - を付けます。swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt には、`salt=2` のように `tag=N` の形式で代替字形の番号を指定できます。N は 0 から {_1} まで) @msg unknown dither `{d}` (diffusion, pattern, noise or none) `{d}` は不明なディザの指定です (diffusion、pattern、noise、none のいずれか) @msg unknown panel `{panel}` `{panel}` は不明なパネルです @msg unknown parameter `{key}` `{key}` は不明なパラメーターです diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index ed3a32f457..f52a225d84 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1457,6 +1457,8 @@ lighting Light სინათლე Leading სტრიქონთა დაშორება No Break გადატანის გარეშე Proportional Metrics პროპორციული მეტრიკა + H or V Style ჰორიზონტალური ან ვერტიკალური სტილი + Roman Italics ლათინური კურსივი Insert Aki Left აკის ჩასმა მარცხნივ Insert Aki Right აკის ჩასმა მარჯვნივ Tsume ცუმე @@ -2439,6 +2441,7 @@ digits Malayalam მალაიალამური Transparent Background გამჭვირვალე ფონი Choose… არჩევა… Copy Fonts Used in the Document დოკუმენტში გამოყენებული შრიფტების კოპირება + Copy Info ინფორმაციის კოპირება Copy Links ბმულების კოპირება Create Report ანგარიშის შექმნა Fonts whose licence doesn't allow embedding are not copied. შრიფტები, რომელთა ლიცენზია ჩაშენების უფლებას არ იძლევა, არ კოპირდება. @@ -5617,6 +5620,7 @@ imageTrace Outline კონტური @msg there is no script engine named {name} სკრიპტის ძრავა სახელით {name} არ არსებობს @msg Running a script სკრიპტი მუშაობს @msg Copied the script report კოპირებულია სკრიპტის ანგარიში +@msg Copied the link info კოპირებულია ბმულის ინფორმაცია @msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) ტრაექტორიის წანაცვლება: წანაცვლების შემდეგ მონიშნული ტრაექტორიებიდან არაფერი რჩება (შიგნით წანაცვლება ფიგურებზე დიდია) @msg {path}: not a file {path}: ფაილი არ არის Load EPS into Clipboard EPS-ის ბუფერში ჩატვირთვა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4a083547f3..74a6bce25e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -44,6 +44,8 @@ font-technology Variable 가변 x-Height x 높이 ICF Box ICF 박스 Proportional Metrics 비례 메트릭 + H or V Style 가로 또는 세로 스타일 + Roman Italics 로마자 이탤릭 Insert Aki Left 앞 아키 삽입 Insert Aki Right 뒤 아키 삽입 Tsume 츠메 @@ -3763,6 +3765,7 @@ gradient-kind Radial 방사형 Convert To Outlines 윤곽선으로 변환 Copy All Objects 모든 개체 복사 Copy Fonts Used in the Document 문서에 사용된 글꼴 복사 + Copy Info 정보 복사 Copy Links 링크 복사 Couldn't copy to the system clipboard: {e} 시스템 클립보드로 복사할 수 없습니다: {e} Couldn't paste from the clipboard: {e} 클립보드에서 붙여넣을 수 없습니다: {e} @@ -5137,6 +5140,7 @@ theme Appearance 외관 @msg there is no script engine named {name} 이름이 {name}인 스크립트 엔진이 없습니다 @msg Running a script 스크립트 실행 중 @msg Copied the script report 스크립트 보고서를 복사했습니다 +@msg Copied the link info 링크 정보를 복사했습니다 @msg Exported {path} {path}을(를) 내보냈습니다 @msg no CSS to copy: select objects with CSS of their own 복사할 CSS가 없습니다: 자체 CSS가 있는 개체를 선택하세요 @msg unknown highlight `{h}` 알 수 없는 강조 `{h}` @@ -8859,7 +8863,7 @@ breakText Glyphs 글리프 @msg unexpected resources 예상하지 못한 리소스 @msg unknown CMYK profile `{name}` 알 수 없는 CMYK 프로파일 `{name}` @msg unknown File Info field `{k}` 알 수 없는 파일 정보 필드 `{k}` -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) 알 수 없는 OpenType 기능 `{t}`(liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt. 끄려면 앞에 -를 붙이세요. swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt는 대체 글리프 번호를 `tag=N` 형식으로 받으며 N은 0에서 {_1} 사이입니다. 예: `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) 알 수 없는 OpenType 기능 `{t}`(liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital. 끄려면 앞에 -를 붙이세요. swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt는 대체 글리프 번호를 `tag=N` 형식으로 받으며 N은 0에서 {_1} 사이입니다. 예: `salt=2`) @msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) featureChanges의 알 수 없는 OpenType 기능 `{t}`(`features`가 받는 태그를 사용하세요) @msg unknown PDF preset `{name}` (presets: {_1}) 알 수 없는 PDF 프리셋 `{name}`(프리셋: {_1}) @msg unknown Warp style 알 수 없는 왜곡 스타일 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 5759f4c5c3..a10e70d46c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2038,6 +2038,8 @@ lighting Light Licht Leading Regelafstand No Break Niet afbreken Proportional Metrics Proportionele metingen + H or V Style Horizontale of verticale stijl + Roman Italics Romeinse cursief Insert Aki Left Aki links invoegen Insert Aki Right Aki rechts invoegen Tsume Tsume @@ -3035,6 +3037,7 @@ digits Malayalam Malayalam Transparent Background Transparante achtergrond Choose… Kies… Copy Fonts Used in the Document In het document gebruikte lettertypen kopiëren + Copy Info Info kopiëren Copy Links Koppelingen kopiëren Create Report Rapport maken Fonts whose licence doesn't allow embedding are not copied. Lettertypen waarvan de licentie insluiten niet toestaat, worden niet gekopieerd. @@ -4817,6 +4820,7 @@ imageTrace Outline Contour Stop the script and undo what it did? Then quit again. Het script stoppen en de wijzigingen ervan ongedaan maken? Sluit daarna opnieuw af. This script has added a great many objects. Continue, or stop it and undo what it did? Dit script heeft heel veel objecten toegevoegd. Doorgaan, of het stoppen en de wijzigingen ervan ongedaan maken? @msg Copied the script report Scriptrapport gekopieerd +@msg Copied the link info Koppelingsinfo gekopieerd @msg Corners edits the corners of a path Hoeken bewerkt de hoeken van een pad @msg Couldn't copy to the system clipboard: {e} Kon niet naar het systeemklembord kopiëren: {e} @msg Couldn't open {name}: {e} Kon {name} niet openen: {e} @@ -7206,7 +7210,7 @@ imageTrace Outline Contour @msg unexpected resources onverwachte resources @msg unknown CMYK profile `{name}` onbekend CMYK-profiel `{name}` @msg unknown File Info field `{k}` onbekend veld `{k}` van Bestandsinfo -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) onbekende OpenType-functie `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; zet er - voor om uit te schakelen; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom en aalt accepteren het nummer van een alternatief als `tag=N` met N van 0 tot {_1}, zoals `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) onbekende OpenType-functie `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; zet er - voor om uit te schakelen; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom en aalt accepteren het nummer van een alternatief als `tag=N` met N van 0 tot {_1}, zoals `salt=2`) @msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) onbekende OpenType-functie `{t}` in featureChanges (de tags die `features` accepteert) @msg unknown PDF preset `{name}` (presets: {_1}) onbekende PDF-voorinstelling `{name}` (voorinstellingen: {_1}) @msg unknown Warp style onbekende warpstijl diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7978c4c6fd..3a3ac2040d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1525,6 +1525,8 @@ lighting Light Światło Leading Interlinia No Break Bez podziału Proportional Metrics Metryka proporcjonalna + H or V Style Styl poziomy lub pionowy + Roman Italics Kursywa łacińska Insert Aki Left Wstaw aki z lewej Insert Aki Right Wstaw aki z prawej Tsume Tsume @@ -2648,6 +2650,7 @@ digits Malayalam Malajalam Transparent Background Przezroczyste tło Choose… Wybierz… Copy Fonts Used in the Document Kopiuj czcionki użyte w dokumencie + Copy Info Kopiuj informacje Copy Links Kopiuj łącza Create Report Utwórz raport Fonts whose licence doesn't allow embedding are not copied. Czcionki, których licencja nie zezwala na osadzanie, nie są kopiowane. @@ -4528,6 +4531,7 @@ imageTrace Outline Kontur @msg there is no script engine named {name} nie ma silnika skryptów o nazwie {name} @msg Running a script Uruchamianie skryptu @msg Copied the script report Skopiowano raport skryptu +@msg Copied the link info Skopiowano informacje o łączu @msg Corners edits the corners of a path Narożniki edytuje narożniki ścieżki @msg Couldn't copy to the system clipboard: {e} Nie udało się skopiować do schowka systemowego: {e} @msg Couldn't open {name}: {e} Nie udało się otworzyć {name}: {e} @@ -7765,7 +7769,7 @@ breakText Glyphs Glify @msg too many arguments zbyt wiele argumentów @msg too many quantifiers zbyt wiele kwantyfikatorów @msg turn on at least one side włącz co najmniej jedną stronę -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) nieznana funkcja OpenType `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefiks - wyłącza funkcję; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom i aalt przyjmują numer wariantu w postaci `tag=N`, gdzie N to liczba od 0 do {_1}, na przykład `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) nieznana funkcja OpenType `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefiks - wyłącza funkcję; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom i aalt przyjmują numer wariantu w postaci `tag=N`, gdzie N to liczba od 0 do {_1}, na przykład `salt=2`) @msg unknown dither `{d}` (diffusion, pattern, noise or none) nieznane rozpraszanie `{d}` (diffusion, pattern, noise lub none) @msg unknown panel `{panel}` nieznany panel `{panel}` @msg unknown parameter `{key}` nieznany parametr `{key}` diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 25a72cd7cd..a39a1f5546 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2235,6 +2235,8 @@ antiAlias Sharp Nítido Proof Setup Configuração de Prova Properties Propriedades Proportional Metrics Métrica Proporcional + H or V Style Estilo Horizontal ou Vertical + Roman Italics Itálico Romano Insert Aki Left Inserir Aki à Esquerda Insert Aki Right Inserir Aki à Direita Tsume Tsume @@ -4907,6 +4909,7 @@ axis Both Ambos Convert To Outlines Converter em Contornos Copy All Objects Copiar Todos os Objetos Copy Fonts Used in the Document Copiar Fontes Usadas no Documento + Copy Info Copiar Informações Copy Links Copiar Links Descender Descendente Flip Virar @@ -5426,6 +5429,7 @@ axis Both Ambos @msg there is no script engine named {name} não há nenhum mecanismo de script chamado {name} @msg Running a script Executando um script @msg Copied the script report Relatório do script copiado +@msg Copied the link info Informações do link copiadas @msg This shortcut file is in a form VectorCraft can't read. Este arquivo de atalhos está em um formato que o VectorCraft não consegue ler. @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis deve ser "vertical", "horizontal" ou um ângulo em graus, não `{v}` @msg give corners of the path: anchors where its sides meet at an angle informe os cantos do caminho: pontos de ancoragem onde os lados se encontram em ângulo @@ -9319,7 +9323,7 @@ swatch Yellow Green Verde-amarelado @msg unexpected resources recursos inesperados @msg unknown CMYK profile `{name}` perfil CMYK desconhecido `{name}` @msg unknown File Info field `{k}` campo de Informações do Arquivo desconhecido `{k}` -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) recurso OpenType desconhecido `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefixe com - para desativar; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom e aalt aceitam o número de uma alternativa como `tag=N` com N de 0 a {_1}, como `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) recurso OpenType desconhecido `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefixe com - para desativar; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom e aalt aceitam o número de uma alternativa como `tag=N` com N de 0 a {_1}, como `salt=2`) @msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) recurso OpenType desconhecido `{t}` em featureChanges (as tags que `features` aceita) @msg unknown PDF preset `{name}` (presets: {_1}) predefinição de PDF desconhecida `{name}` (predefinições: {_1}) @msg unknown Warp style estilo de Deformação desconhecido diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8c38fae850..9cd4b3542e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -644,6 +644,7 @@ font-technology Variable Вариативные Copy All Styles Копировать все стили Copy Color Value (Hex) Копировать значение цвета (hex) Copy Fonts Used in the Document Копировать шрифты, использованные в документе + Copy Info Копировать сведения Copy Links Копировать связи Copy Selected Style Копировать выбранный стиль Copy to User Defined Копировать в «Пользовательский» @@ -2699,6 +2700,8 @@ mark-style Japanese Японский Proof Setup Настройка цветопробы Properties Свойства Proportional Metrics Пропорциональные метрики + H or V Style Горизонтальный или вертикальный стиль + Roman Italics Римский курсив Insert Aki Left Вставить аки слева Insert Aki Right Вставить аки справа Tsume Цумэ @@ -4430,6 +4433,7 @@ axis Both Оба @msg there is no script engine named {name} движка скриптов с именем {name} нет @msg Running a script Выполняется скрипт @msg Copied the script report Отчёт о сценарии скопирован +@msg Copied the link info Сведения о связи скопированы @msg Corners edits the corners of a path «Углы» редактирует углы контура @msg Couldn't copy to the system clipboard: {e} Не удалось скопировать в системный буфер обмена: {e} @msg Couldn't open {name}: {e} Не удалось открыть {name}: {e} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index f53031eba1..e26bddc2ad 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -633,6 +633,7 @@ font-technology Variable Variabla Copy All Styles Kopiera alla stilar Copy Color Value (Hex) Kopiera färgvärde (hex) Copy Fonts Used in the Document Kopiera typsnitt som används i dokumentet + Copy Info Kopiera info Copy Links Kopiera länkar Copy Selected Style Kopiera markerad stil Copy to User Defined Kopiera till användardefinierade @@ -2607,6 +2608,8 @@ font-technology Variable Variabla Proof Setup Provtrycksinställningar Properties Egenskaper Proportional Metrics Proportionella mått + H or V Style Vågrät eller lodrät stil + Roman Italics Romersk kursiv Insert Aki Left Infoga aki till vänster Insert Aki Right Infoga aki till höger Tsume Tsume @@ -4303,6 +4306,7 @@ axis Both Båda @msg there is no script engine named {name} det finns ingen skriptmotor med namnet {name} @msg Running a script Kör ett skript @msg Copied the script report Kopierade skriptrapporten +@msg Copied the link info Kopierade länkinfo @msg Corners edits the corners of a path Hörn redigerar hörnen på en bana @msg Couldn't copy to the system clipboard: {e} Kunde inte kopiera till systemets urklipp: {e} @msg Couldn't open {name}: {e} Kunde inte öppna {name}: {e} diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 7fc16807ec..55295f011a 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -49,6 +49,9 @@ fn hindi_resolves_locales_and_uses_zero_one_plurals() { /// Every Hindi row uses the terms `docs/localization-hi.md` sets for what its English names: /// सेव for saving, स्वॉच for a swatch, क्रॉप for cropping, रिज़ॉल्यूशन for a resolution and पाथ for /// a path, never पाठ (a reading), and a number of points is a number (संख्या) in points (पॉइंट). +/// Rows also never use a word's unrelated sense: a ruler is not शासक (a sovereign), a text character +/// is not चरित्र (a personality), a vector point or a printer's mark is not अंक (a digit), fills are +/// not भरता (the verb), Desired is not देसी (local), Even is not शाम (evening), and so on. #[test] fn hindi_rows_use_the_glossary_term_for_what_their_english_names() { let hi = Lang::from_code("hi").expect("hi registered"); @@ -57,15 +60,35 @@ fn hindi_rows_use_the_glossary_term_for_what_their_english_names() { for (_, src, translation) in &entries { let english = src.to_lowercase(); let names = |words: &[&str]| english.split(|c: char| !c.is_ascii_alphabetic()).any(|w| words.contains(&w)); - let banned: [(bool, &[&str]); 6] = [ - (names(&["save", "saves", "saved", "saving"]), &["बचा", "सहेज"]), + let banned: &[(bool, &[&str])] = &[ + (names(&["save", "saves", "saved", "saving"]), &["बचा", "बचत", "सहेज"]), (names(&["swatch", "swatches"]), &["घड़ी"]), (names(&["crop"]), &["फसल"]), (names(&["resolution", "resolutions"]), &["संकल्प"]), (names(&["path", "paths"]), &["पथ"]), (true, &["पाठ", "कई अंक"]), + (names(&["ruler", "rulers"]), &["शासक"]), + (names(&["character", "characters"]), &["चरित्र"]), + (names(&["point", "points", "mark", "marks"]), &["अंक"]), + (names(&["fill", "fills"]), &["भरता"]), + (names(&["desired"]), &["देसी", "इच्छा"]), + (names(&["even"]), &["शाम"]), + (names(&["force", "forced"]), &["सेना"]), + (names(&["same"]), &["समीर"]), + (names(&["thumbnail", "thumbnails"]), &["हिंदुस्तान"]), + (names(&["contrast"]), &["न्यायालय"]), + (names(&["function"]), &["समारोह"]), + (names(&["kern"]), &["बर्न"]), + (names(&["imageable"]), &["कल्पनाशील"]), + (names(&["constrain"]), &["कंस्ट्रक्शन"]), + (names(&["complement"]), &["संगम"]), + (names(&["concave"]), &["अवतरण"]), + (names(&["triad"]), &["त्रिद"]), + (names(&["trapped", "traps"]), &["निशानेबाज़", "जाल"]), + (names(&["alternate", "alternates"]), &["अल्टरनेटर"]), + (names(&["highlight"]), &["उच्च प्रकाश"]), ]; - for (applies, words) in banned { + for &(applies, words) in banned { if let Some(word) = words.iter().find(|w| applies && translation.contains(**w)) { wrong.push(format!("{word} in {src:?} -> {translation:?}")); } @@ -135,6 +158,10 @@ fn hindi_rows_keep_the_meaning_of_their_english() { "पेज थंबनेल RGB पिक्सल का होना चाहिए, हर तरफ़ 1 से 256 (मिला 0 × 4)" ); assert_eq!(message(hi, "invalid angle"), "अमान्य कोण"); + assert_eq!(tr(hi, "Desired"), "वांछित"); + let range = "न्यूनतम, वांछित से अधिक नहीं होना चाहिए और वांछित, अधिकतम से अधिक नहीं होना चाहिए"; + assert_eq!(tr(hi, "Minimum must not exceed Desired, nor Desired Maximum."), format!("{range}।")); + assert_eq!(message(hi, "`wordSpacing`: Minimum must not exceed Desired, nor Desired Maximum"), format!("`wordSpacing`: {range}")); } /// Hindi messages keep every `a|b|c` list of accepted values exactly as their English writes it. @@ -1887,6 +1914,13 @@ fn uk() -> Lang { Lang::from_code("uk").expect("uk registered") } +/// The Ukrainian Color Library labels use the word for colors (кольорів), not flowers (квітів). +#[test] +fn ukrainian_color_library_labels_name_colors() { + assert_eq!(uk().catalog().plain("Color Library"), Some("Бібліотека кольорів")); + assert_eq!(uk().catalog().plain("Color Library…"), Some("Бібліотека кольорів…")); +} + /// Every Ukrainian plural has all three forms; exercise different endings through real lookups. #[test] fn ukrainian_plurals_have_three_forms() { diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c8c1725831..1ca8f00dc5 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -625,6 +625,7 @@ font-technology Variable Değişken Copy All Styles Tüm Stilleri Kopyala Copy Color Value (Hex) Renk Değerini Kopyala (Hex) Copy Fonts Used in the Document Belgede Kullanılan Yazı Tiplerini Kopyala + Copy Info Bilgiyi Kopyala Copy Links Bağlantıları Kopyala Copy Selected Style Seçilen Stili Kopyala Copy to User Defined Kullanıcı Tanımlıya Kopyala @@ -2454,6 +2455,8 @@ shape Open Açık Proof Setup Prova Kurulumu Properties Özellikler Proportional Metrics Orantılı Metrikler + H or V Style Yatay veya Dikey Stil + Roman Italics Roma İtalik Insert Aki Left Sola Aki Ekle Insert Aki Right Sağa Aki Ekle Tsume Tsume @@ -4166,6 +4169,7 @@ axis Both Her İkisi @msg there is no script engine named {name} {name} adlı bir betik motoru yok @msg Running a script Betik çalıştırılıyor @msg Copied the script report Komut dosyası raporu kopyalandı +@msg Copied the link info Bağlantı bilgisi kopyalandı @msg Corners edits the corners of a path Köşeler bir yolun köşelerini düzenler @msg Couldn't copy to the system clipboard: {e} Sistem panosuna kopyalanamadı: {e} @msg Couldn't open {name}: {e} {name} açılamadı: {e} @@ -7118,7 +7122,7 @@ axis Y Axis Y ekseni @msg toString is not a function toString bir işlev değil @msg too many arguments çok fazla bağımsız değişken @msg too many quantifiers çok fazla niceleyici -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) bilinmeyen OpenType özelliği `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; kapatmak için başına - koyun; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom ve aalt bir alternatifin numarasını `tag=N` biçiminde alır, N 0 ile {_1} arasındadır, örneğin `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) bilinmeyen OpenType özelliği `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; kapatmak için başına - koyun; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom ve aalt bir alternatifin numarasını `tag=N` biçiminde alır, N 0 ile {_1} arasındadır, örneğin `salt=2`) @msg unknown dither `{d}` (diffusion, pattern, noise or none) bilinmeyen renk taklidi `{d}` (diffusion, pattern, noise veya none) @msg unknown panel `{panel}` bilinmeyen panel `{panel}` @msg unknown parameter `{key}` bilinmeyen parametre `{key}` diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ac74b2ae51..942896ce16 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -541,8 +541,8 @@ font-technology Variable Варіативні Color Guide Options Параметри колірного гіда Color Guide Options… Параметри колірного гіда… Color images Кольорові зображення - Color Library Бібліотека квітів - Color Library… Бібліотека квітів… + Color Library Бібліотека кольорів + Color Library… Бібліотека кольорів… Color Management Керування кольором Color Mixer (Shift-click the chip) Змішувач кольорів (Shift+клацання на зразку) Color Mode Колірний режим @@ -640,6 +640,7 @@ font-technology Variable Варіативні Copy All Styles Копіювати всі стилі Copy Color Value (Hex) Копіювати значення кольору (hex) Copy Fonts Used in the Document Копіювати шрифти, використані у документі + Copy Info Копіювати відомості Copy Links Копіювати зв’язки Copy Selected Style Копіювати вибраний стиль Copy to User Defined Копіювати в «Користувацькі» @@ -2704,6 +2705,8 @@ mark-style Japanese Японський Proof Setup Налаштування кольоропроби Properties Властивості Proportional Metrics Пропорційні метрики + H or V Style Горизонтальний або вертикальний стиль + Roman Italics Римський курсив Insert Aki Left Вставити акі ліворуч Insert Aki Right Вставити акі праворуч Tsume Цуме @@ -4429,6 +4432,7 @@ axis Both Обидві @msg there is no script engine named {name} рушія скриптів з назвою {name} немає @msg Running a script Виконується скрипт @msg Copied the script report Звіт про сценарій скопійовано +@msg Copied the link info Відомості про зв’язок скопійовано @msg Corners edits the corners of a path «Кути» редагує кути контуру @msg Couldn't copy to the system clipboard: {e} Не вдалося скопіювати до системного буфера обміну: {e} @msg Couldn't open {name}: {e} Не вдалося відкрити {name}: {e} @@ -8848,7 +8852,7 @@ breakText Glyphs Гліфи @msg unexpected resources неочікувані ресурси @msg unknown CMYK profile `{name}` невідомий профіль CMYK `{name}` @msg unknown File Info field `{k}` невідоме поле відомостей про файл `{k}` -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) невідома функція OpenType `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; префікс - вимикає функцію; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom і aalt приймають номер альтернативи у вигляді `tag=N`, де N від 0 до {_1}, наприклад `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) невідома функція OpenType `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; префікс - вимикає функцію; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom і aalt приймають номер альтернативи у вигляді `tag=N`, де N від 0 до {_1}, наприклад `salt=2`) @msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) невідома функція OpenType `{t}` у featureChanges (допустимі теги, які приймає `features`) @msg unknown PDF preset `{name}` (presets: {_1}) невідомий набір PDF `{name}` (набори: {_1}) @msg unknown Warp style невідомий стиль деформації diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 132d7db17d..32f3a808ed 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -739,6 +739,7 @@ font-technology Variable Biến thiên Copy Color Value (Hex) Sao chép giá trị màu (Hex) Copy Fonts Used in the Document Sao chép phông đã dùng trong tài liệu Copy into the document's folder Sao chép vào thư mục tài liệu + Copy Info Sao chép thông tin Copy Links Sao chép liên kết Copy only fonts you own or are licensed to install. Chỉ sao chép phông chữ bạn sở hữu hoặc có giấy phép cài đặt. Copy Selected Style Sao chép kiểu đã chọn @@ -3016,6 +3017,8 @@ mark-style Japanese Kiểu Nhật Proof Setup Thiết lập thử nghiệm Properties Thuộc tính Proportional Metrics Số liệu theo tỷ lệ + H or V Style Kiểu ngang hoặc dọc + Roman Italics Nghiêng Roman Insert Aki Left Chèn aki bên trái Insert Aki Right Chèn aki bên phải Tsume Tsume @@ -4859,6 +4862,7 @@ theme Appearance Giao diện @msg there is no script engine named {name} không có bộ máy tập lệnh nào tên {name} @msg Running a script Đang chạy tập lệnh @msg Copied the script report Đã sao chép báo cáo script +@msg Copied the link info Đã sao chép thông tin liên kết @msg Corners edits the corners of a path Corners chỉnh sửa các góc của đường dẫn @msg could not decode image: {0} không thể giải mã ảnh: {0} @msg Couldn't copy to the system clipboard: {e} Không thể sao chép vào khay nhớ tạm hệ thống: {e} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b20d55fc78..0d280fb342 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -591,6 +591,7 @@ antiAlias Sharp 锐利 Copy All Styles 复制所有样式 Copy Color Value (Hex) 复制颜色值 (十六进制) Copy Fonts Used in the Document 复制文档中使用的字体 + Copy Info 复制信息 Copy Links 复制链接 Copy Selected Style 复制所选样式 Copy to User Defined 复制到用户定义 @@ -2566,6 +2567,8 @@ font-script Japanese 汉字 Proof Setup 校样设置 Properties 属性 Proportional Metrics 比例度量 + H or V Style 横排或直排样式 + Roman Italics 西文斜体 Insert Aki Left 在前方插入空白 Insert Aki Right 在后方插入空白 Tsume 比例间距 @@ -4889,6 +4892,7 @@ imageTrace Outline 轮廓 @msg there is no script engine named {name} 没有名为 {name} 的脚本引擎 @msg Running a script 正在运行脚本 @msg Copied the script report 已复制脚本报告 +@msg Copied the link info 已复制链接信息 @msg Exported {path} 已导出 {path} @msg no CSS to copy: select objects with CSS of their own 没有可复制的 CSS:请选择带有自身 CSS 的对象 @msg unknown highlight `{h}` 未知的高亮显示 `{h}` @@ -7746,7 +7750,7 @@ breakText Glyphs 字形 @msg too many arguments 参数过多 @msg too many quantifiers 量词过多 @msg turn on at least one side 请至少打开一条边 -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) 未知的 OpenType 特性 `{t}` (liga、calt、dlig、smcp、frac、onum、tnum、ordn、swsh、ss01–ss20、salt、titl、pnum、lnum、sups、subs、numr、dnom、aalt;加前缀 - 表示关闭;swsh、ss01–ss20、salt、titl、pnum、lnum、sups、subs、numr、dnom 和 aalt 可用 `tag=N` 指定替代字形的编号,N 为 0 到 {_1},例如 `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) 未知的 OpenType 特性 `{t}` (liga、calt、dlig、smcp、frac、onum、tnum、ordn、swsh、ss01–ss20、salt、titl、pnum、lnum、sups、subs、numr、dnom、aalt、hkna、ital;加前缀 - 表示关闭;swsh、ss01–ss20、salt、titl、pnum、lnum、sups、subs、numr、dnom 和 aalt 可用 `tag=N` 指定替代字形的编号,N 为 0 到 {_1},例如 `salt=2`) @msg unknown dither `{d}` (diffusion, pattern, noise or none) 未知的仿色 `{d}` (diffusion、pattern、noise 或 none) @msg unknown panel `{panel}` 未知的面板 `{panel}` @msg unknown parameter `{key}` 未知的参数 `{key}` diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 700215d2dc..649dd294c2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -608,6 +608,7 @@ font-technology Variable 可變 Copy All Styles 複製所有樣式 Copy Color Value (Hex) 複製顏色值 (Hex) Copy Fonts Used in the Document 複製文件中使用的字體 + Copy Info 複製資訊 Copy Links 複製連結 Copy Selected Style 複製所選樣式 Copy to User Defined 複製到使用者定義 @@ -2590,6 +2591,7 @@ file-filter Templates 範本 @msg there is no script engine named {name} 沒有名為 {name} 的指令碼引擎 @msg Running a script 正在執行指令碼 @msg Copied the script report 已複製指令碼報告 +@msg Copied the link info 已複製連結資訊 @msg Exported {path} 已匯出 {path} @msg no CSS to copy: select objects with CSS of their own 沒有可複製的 CSS:請選取有自己 CSS 的物件 @msg unknown highlight `{h}` 未知的反白顯示 `{h}` @@ -3785,6 +3787,8 @@ font-script Japanese 漢字 Proof Setup 校樣設定 Properties 內容 Proportional Metrics 比例度量 + H or V Style 橫排或直排樣式 + Roman Italics 羅馬字斜體 Insert Aki Left 在前方插入空白 Insert Aki Right 在後方插入空白 Tsume 比例間距 @@ -7674,7 +7678,7 @@ breakText Glyphs 字符 @msg too many arguments 引數過多 @msg too many quantifiers 量詞過多 @msg turn on at least one side 請至少開啟一側 -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) 未知的 OpenType 功能 `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt;加上前置的 - 可關閉;swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom 和 aalt 可用 `tag=N` 指定替代字的編號,N 為 0 到 {_1},例如 `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) 未知的 OpenType 功能 `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital;加上前置的 - 可關閉;swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom 和 aalt 可用 `tag=N` 指定替代字的編號,N 為 0 到 {_1},例如 `salt=2`) @msg unknown dither `{d}` (diffusion, pattern, noise or none) 未知的仿色 `{d}` (diffusion、pattern、noise 或 none) @msg unknown panel `{panel}` 未知的面板 `{panel}` @msg unknown parameter `{key}` 未知的參數 `{key}` diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 7b7f7a6b21..6efd472675 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -845,6 +845,7 @@ fn run_ui_part8(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::navigator::set_options(app, p), "ui.layersExpand" => crate::panels::layers::expand(app, p), "ui.layersFilter" => crate::panels::layers::set_filter(app, p), + "ui.panelView" => crate::panels::set_view(app, p), "ui.flattenTransparencyDialog" => { crate::dialogs::flatten::open(app); Ok(Value::Null) @@ -976,6 +977,7 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::links::open_file(app, p, false), "links.reveal" => crate::panels::links::open_file(app, p, true), + "links.copyInfo" => crate::panels::links::copy_info(app, p), "ui.placementOptionsDialog" => crate::dialogs::placement_options::open(app, p), "ui.relinkDialog" => crate::dialogs::relink::open(app, p), "ui.findLinksInFolder" => { diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 0d5775ffe9..98c13ec06e 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -310,6 +310,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.dxfOptionsDialog" => app.session.active().is_some(), "links.editOriginal" | "links.reveal" => selected_image(app, |im| im.link.is_some()) || selected_placed(app), "ui.placementOptionsDialog" => selected_image(app, |_| true) || selected_placed(app), + "links.copyInfo" => !crate::panels::links::selected_rows_of_app(app).is_empty(), "ui.relinkDialog" => app.services.pick_open.is_some() && (selected_image(app, |_| true) || selected_placed(app)), "ui.findLinksInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), "ui.packageDialog" | "docInfo.save" => app.session.active().is_some(), diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index d8ca0b79ec..1bb152292e 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -1008,6 +1008,57 @@ fn expand_appearance_is_an_object_menu_item_only() { assert_eq!(paths, [vec!["Object".to_string()]]); } +/// Object › Distribute follows Object › Align and lists the Align panel's Distribute Objects and +/// Distribute Spacing buttons, each item running the button's command: dimmed with nothing +/// selected, spacing three objects evenly, and with a key object sending the Distribute Spacing value. +#[test] +fn object_distribute_runs_the_distribute_buttons() { + let object = menu_tree().into_iter().find(|(t, _)| *t == "Object").map(|(_, items)| items).unwrap_or_default(); + let subs: Vec<&str> = object.iter().filter_map(|it| if let Item::Sub(l, _) = it { Some(*l) } else { None }).collect(); + let align = subs.iter().position(|l| *l == "Align").unwrap_or_else(|| panic!("Object › Align: {subs:?}")); + assert_eq!(subs.get(align + 1), Some(&"Distribute"), "{subs:?}"); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let items = |app: &VectorcraftApp| -> Vec { menu_entries(app).into_iter().filter(|e| e.path == ["Object", "Distribute"]).collect() }; + let listed: Vec<(String, Option)> = items(&app).into_iter().map(|e| (e.label, e.command)).collect(); + let expected: Vec<(String, Option)> = [ + ("Horizontal Distribute Left", "align.distributeHorizontalLeft"), + ("Horizontal Distribute Center", "align.distributeHorizontalCenter"), + ("Horizontal Distribute Right", "align.distributeHorizontalRight"), + ("Vertical Distribute Top", "align.distributeVerticalTop"), + ("Vertical Distribute Center", "align.distributeVerticalCenter"), + ("Vertical Distribute Bottom", "align.distributeVerticalBottom"), + ("Horizontal Distribute Space", "align.distributeHorizontalSpace"), + ("Vertical Distribute Space", "align.distributeVerticalSpace"), + ] + .into_iter() + .map(|(l, id)| (l.to_string(), Some(id.to_string()))) + .collect(); + assert_eq!(listed, expected); + assert!(items(&app).iter().all(|e| !e.enabled), "nothing selected"); + let ids: Vec = [0, 30, 100] + .into_iter() + .map(|x| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone()) + .collect(); + app.run("select.set", json!({"ids": ids})).unwrap(); + let lefts = |app: &VectorcraftApp| -> Vec { + let doc = &app.session.active().unwrap().doc; + ids.iter().map(|id| doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap().x0).collect() + }; + let click = |app: &mut VectorcraftApp, label: &str| { + let e = items(app).into_iter().find(|e| e.label == label).unwrap_or_else(|| panic!("{label}")); + assert!(e.enabled, "{label}"); + let (id, p) = click_target(&e.label, e.command.as_deref().unwrap_or_default(), &e.params); + invoke(app, &id, p); + }; + click(&mut app, "Horizontal Distribute Left"); + assert_eq!(lefts(&app), [0.0, 50.0, 100.0], "{}", app.ui.status); + app.ui.align_spacing = 5.0; + app.run("select.key", json!({"id": ids[0]})).unwrap(); + click(&mut app, "Horizontal Distribute Space"); + assert_eq!(lefts(&app), [0.0, 15.0, 30.0], "{}", app.ui.status); +} + /// Isolation keeps the Lock and Hide actions available for selected members. #[test] fn lock_and_hide_menus_are_enabled_in_isolation_mode() { diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index cc63a489cf..b718b24080 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -165,6 +165,23 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { cp("Vertical Align Bottom", "object.align", json!({"vertical": "bottom"})), ], ), + // The Align panel's Distribute Objects and Distribute Spacing buttons: each item runs + // what a click on its button runs (`crate::panels::align::run_button`). + sub( + "Distribute", + vec![ + c("Horizontal Distribute Left", "align.distributeHorizontalLeft"), + c("Horizontal Distribute Center", "align.distributeHorizontalCenter"), + c("Horizontal Distribute Right", "align.distributeHorizontalRight"), + Sep, + c("Vertical Distribute Top", "align.distributeVerticalTop"), + c("Vertical Distribute Center", "align.distributeVerticalCenter"), + c("Vertical Distribute Bottom", "align.distributeVerticalBottom"), + Sep, + c("Horizontal Distribute Space", "align.distributeHorizontalSpace"), + c("Vertical Distribute Space", "align.distributeVerticalSpace"), + ], + ), Sep, c("Group", "object.group"), c("Ungroup", "object.ungroup"), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 913dc03abb..24a39c9335 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1088,6 +1088,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{objects?: [paths|shapes|text|images|effects|groups|linkedFiles|guides], layers?: [locked|hidden]} set the Layers panel's Filter (By Object, By Layer): a row stays when it is of a kind in `objects` and in a state in `layers` (its own lock or eye; an empty list takes every row), or when a row inside it stays; Search All narrows the rows further. A key left out keeps its list. The filter lasts the session and does not change the document, its open rows or the selection → {objects, layers}", ), + ( + "ui.panelView", + "Set Panel View", + "", + "{panel: swatches|graphicStyles|symbols, view?} set a panel's view, as the view items of its panel menu do: for swatches smallThumb|mediumThumb (the default)|largeThumb|smallList|largeList, for graphicStyles and symbols thumbnail (the default)|smallList|largeList; without `view`, answer the current one. The view lasts the session and does not change the document; an unknown panel or view is an error and changes nothing → {panel, view}", + ), ( "ui.tileEdgeColor", "Tile Edge Color…", @@ -1456,6 +1462,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{id?} show the file of linked image `id` (default: the selected linked image) in the system's file manager → {path}", ), + ( + "links.copyInfo", + "Copy Info", + "", + "{ids?} copy the file names of images and placed documents `ids` (default: the selected ones and the rows picked in the Links panel, in links.list order) to the clipboard, one per line in that order; an embedded image gives the name its Links panel row shows (Links panel menu) → {text, names}", + ), ( "ui.placementOptionsDialog", "Placement Options…", diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index bbe91da5a6..a56bcd7128 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -514,6 +514,42 @@ pub(crate) fn set_pstate(ctx: &egui::Context, ctx.data_mut(|d| d.insert_temp(egui::Id::unique(("panel-state", key)), v)); } +/// `ui.panelView {panel, view?}`: set the view of the Swatches, Graphic Styles or Symbols panel +/// (`panel`: swatches, graphicStyles or symbols), as the view items of its panel menu do; without +/// `view`, answer the current one. An unknown panel or view is an error, and then nothing changes. +/// → {panel, view} +pub(crate) fn set_view(app: &mut VectorcraftApp, p: &Value) -> Result { + fn given(p: &Value, err: &str) -> Result, String> { + match p.get("view") { + None | Some(Value::Null) => Ok(None), + Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|_| err.to_string()), + } + } + let ui = &mut app.ui; + let (panel, view) = match p.get("panel").and_then(Value::as_str) { + Some("swatches") => { + if let Some(v) = given(p, "`view` is smallThumb, mediumThumb, largeThumb, smallList or largeList")? { + ui.swatches_view = v; + } + ("swatches", json!(ui.swatches_view)) + } + Some("graphicStyles") => { + if let Some(v) = given(p, "`view` is thumbnail, smallList or largeList")? { + ui.graphic_styles_view = v; + } + ("graphicStyles", json!(ui.graphic_styles_view)) + } + Some("symbols") => { + if let Some(v) = given(p, "`view` is thumbnail, smallList or largeList")? { + ui.symbols_view = v; + } + ("symbols", json!(ui.symbols_view)) + } + _ => return Err("`panel` is swatches, graphicStyles or symbols".to_string()), + }; + Ok(json!({ "panel": panel, "view": view })) +} + /// Whether the panel whose state is `key` has the keyboard for its rows (Delete and Backspace /// remove its highlighted rows): it took it with a click on a row ([`set_row_keys`]) and kept it in /// every frame since, so it lapses when the panel isn't drawn. diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 4bab22d52e..a7f32611cc 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -312,7 +312,7 @@ pub fn default_sets() -> Vec { Action::new("Center on Artboard", vec![step("object.align", json!({"horizontal": "center", "vertical": "center", "to": "artboard"}))]), Action::new("Outline Text", vec![step("type.createOutlines", json!({}))]), Action::new("Rotate 90° CW", vec![step("object.rotate", json!({"angle": -90}))]), - Action::new("Reflect Horizontal", vec![step("object.reflect", json!({"axis": "vertical"}))]), + Action::new("Reflect Horizontal", vec![step("object.reflect", json!({"axis": "vertical", "basis": "box"}))]), ], )] } @@ -2721,6 +2721,28 @@ mod tests { } } + /// The built-in Reflect Horizontal reflects a rotated object across its bounding box's own axis, + /// as the Reflect dialog does, so the box keeps its angle; a saved step without a basis, recorded + /// before Reflect followed the box, still reflects across the page's axis. + #[test] + fn the_built_in_reflect_follows_the_turned_box() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + let id = vectorcraft_doc::NodeId( + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].as_u64().unwrap(), + ); + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + app.run("object.rotate", json!({"angle": 30})).unwrap(); + let angle = |app: &VectorcraftApp| app.session.active().unwrap().doc.bbox_angle(&[id]); + let reflect = default_sets()[0].actions.iter().position(|a| a.name == "Reflect Horizontal").unwrap(); + app.ui.action_sets = default_sets(); + play(&mut app, 0, reflect).unwrap(); + assert!((angle(&app) - 30.0).abs() < 1e-6, "{}", angle(&app)); + app.ui.action_sets = vec![ActionSet::new("Old", vec![Action::new("Reflect", vec![("object.reflect".into(), json!({"axis": "vertical"}))])])]; + play(&mut app, 0, 0).unwrap(); + assert!((angle(&app) + 30.0).abs() < 1e-6, "{}", angle(&app)); + } + /// An action recorded with a key its command no longer reads still plays, accelerated and one /// step at a time, though the same command run by itself is refused. #[test] diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index b87f291e67..d7b622ba77 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -30,9 +30,10 @@ use crate::menus::Item; use crate::theme::Tokens; use crate::widgets::{self, PanelDrag, menu_item}; -/// The panel's views. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -enum View { +/// The panel's views (`ui.panelView {panel: "graphicStyles", view}`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum View { #[default] Thumbnail, SmallList, @@ -463,7 +464,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let looks = look_hashes(ui.ctx(), app); let shown = Shown { d: &doc, looks: &looks, preview: pstate(ui.ctx(), "gs-preview") }; let sel = selected(ui.ctx(), &doc); - let view: View = pstate(ui.ctx(), "gs-view"); + let view = app.ui.graphic_styles_view; let mut ev = Events::default(); // In a sized panel the list fills the space above the footer (#2992), as the Artboards list // does; elsewhere it keeps its 220 pt. @@ -605,10 +606,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("graphicStyle.sortByName", json!({})).ok(); } ui.separator(); - let view: View = pstate(ui.ctx(), "gs-view"); + let view = app.ui.graphic_styles_view; for (v, label) in View::ALL { - if menu_item(ui, tl!(label), true, view == v) { - set_pstate(ui.ctx(), "gs-view", v); + if menu_item(ui, tl!(label), true, view == v) + && let Err(e) = app.run("ui.panelView", json!({ "panel": "graphicStyles", "view": v })) + { + app.status(e); } } ui.separator(); @@ -865,7 +868,7 @@ mod tests { app.session.execute("graphicStyle.new", &json!({"name": format!("Style {i:02}")})).unwrap(); } let ctx = egui::Context::default(); - set_pstate(&ctx, "gs-view", View::LargeList); + app.ui.graphic_styles_view = View::LargeList; // The clip rect of the first style's name: the list's visible area. let mut list = |bottom: Option| { let mut clip = None; @@ -891,7 +894,7 @@ mod tests { let mut app = app(); app.session.execute("graphicStyle.new", &json!({"name": "Mine"})).unwrap(); let ctx = egui::Context::default(); - set_pstate(&ctx, "gs-view", View::LargeList); + app.ui.graphic_styles_view = View::LargeList; let texts = frame(&ctx, |ui| { show(&mut app, ui); menu(&mut app, ui); @@ -906,7 +909,7 @@ mod tests { let unused: Vec = serde_json::from_value(app.session.execute("graphicStyle.unused", &json!({})).unwrap()["names"].clone()).unwrap(); assert_eq!(unused.len(), 4); set_pstate(&ctx, "gs-sel", unused.clone()); - set_pstate(&ctx, "gs-view", View::SmallList); + app.ui.graphic_styles_view = View::SmallList; frame(&ctx, |ui| show(&mut app, ui)); // Delete asks first; OK deletes, and the panel selection follows. delete(&mut app, &unused, false); @@ -967,7 +970,7 @@ mod tests { // A style dragged onto the top half of the first row lands first (Small List View: rows // of 18 points from the top of the list). - set_pstate(&ctx, "gs-view", View::SmallList); + app.ui.graphic_styles_view = View::SmallList; frame(&ctx, |ui| show(&mut app, ui)); let release = |at: egui::Pos2| { let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; @@ -992,7 +995,7 @@ mod tests { let n = names(&app); assert_eq!(n.len(), 5); let ctx = egui::Context::default(); - set_pstate(&ctx, "gs-view", View::SmallList); + app.ui.graphic_styles_view = View::SmallList; frame(&ctx, |ui| show(&mut app, ui)); // A click on row `i` (Small List View: rows of 18 points, 3 apart) with `modifiers` held, // a second after the last one so that two clicks on a row are not a double-click. diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 4593ab70c7..611c9e472a 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -5,8 +5,8 @@ //! Go To, Update and Edit Original act on the selected rows (Relink through its dialog, with the //! Replace choice: `ui.relinkDialog`); Link Info shows the details of the first. The flyout adds Relink to Folder, Find in Folder (Find Linked Files: //! `ui.findLinksInFolder`), Embed / Unembed, Placement Options, Select Linked / Embedded / Missing / -//! Modified (`addon.select.byLinkStatus`, Shift adds), Show filters, Sort, thumbnail size, -//! Show in Folder and Show DCS Transparency Interactions (a warning on the rows of images placed +//! Modified (`addon.select.byLinkStatus`, Shift adds), Show filters, Sort, Copy Info (the selected +//! rows' file names to the clipboard: `links.copyInfo`), thumbnail size, Show in Folder and Show DCS Transparency Interactions (a warning on the rows of images placed //! from DCS files that are transparent or overlap transparent objects). //! //! Images and placed documents in opacity-mask art and in the art of symbols, graph designs and @@ -218,6 +218,15 @@ fn selected_links(app: &VectorcraftApp, ctx: &egui::Context) -> Vec { ids } +/// [`selected_links`] with the app's window context, for a command run outside the panel (the +/// selected images and placed documents alone when there is no window). +pub(crate) fn selected_rows_of_app(app: &VectorcraftApp) -> Vec { + match app.ui_ctx.clone() { + Some(ctx) => selected_links(app, &ctx), + None => selected_images(app), + } +} + /// The rows of `list` whose image is selected. fn selected_rows<'a>(list: &'a Value, selected: &[u64]) -> Vec<&'a Value> { list["links"].as_array().into_iter().flatten().filter(|r| r["id"].as_u64().is_some_and(|id| selected.contains(&id))).collect() @@ -357,6 +366,48 @@ pub(crate) fn open_file(app: &mut VectorcraftApp, p: &Value, reveal: bool) -> Re Ok(json!({ "path": path })) } +/// The UI command `links.copyInfo {ids?}` (the panel menu's Copy Info): copy the file names of +/// images and placed documents `ids` (default: the selected rows, [`selected_rows_of_app`], in the +/// order `links.list` gives them; `null` counts as left out) to the clipboard, one per line in that +/// order → `{text, names}`. An embedded image gives the name its row shows. +pub(crate) fn copy_info(app: &mut VectorcraftApp, p: &Value) -> Result { + let key = "ids"; + let given = match p.get(key) { + None | Some(Value::Null) => None, + Some(Value::Array(a)) => { + let mut out = Vec::with_capacity(a.len()); + for (index, value) in a.iter().enumerate() { + out.push(value.as_u64().ok_or_else(|| format!("`{key}` entry {index} is {value}, not a non-negative integer object id"))?); + } + Some(out) + } + Some(_) => return Err(format!("{key} must be an array of non-negative integer object ids")), + }; + let list = app.session.execute("links.list", &json!({})).map_err(|e| e.to_string())?; + let rows: Vec<&Value> = list["links"].as_array().into_iter().flatten().collect(); + let wanted = match given { + Some(wanted) => wanted, + None => { + let sel = selected_rows_of_app(app); + ids(&rows, |r| r["id"].as_u64().is_some_and(|id| sel.contains(&id))) + } + }; + let by_id: std::collections::HashMap = + rows.iter().filter_map(|r| Some((r["id"].as_u64()?, r["name"].as_str().unwrap_or_default()))).collect(); + let mut names = Vec::with_capacity(wanted.len()); + for id in &wanted { + let name = by_id.get(id).ok_or_else(|| format!("object {} is not an image or a linked file", *id))?; + names.push((*name).to_string()); + } + if names.is_empty() { + return Err("select the images, or pass ids".into()); + } + let text = names.join("\n"); + app.clipboard_out = Some(text.clone()); + app.status("Copied the link info"); + Ok(json!({ "text": text, "names": names })) +} + /// Whether Edit Original may hand `path` to the system's default app: a regular file (after /// following links) whose name, and the name it links to, have the extension of a format /// VectorCraft reads. Anything else (a folder or app bundle, a script, a shortcut) is refused. @@ -732,7 +783,8 @@ fn link_info(ui: &mut Ui, info: Option<&Value>, units: vectorcraft_doc::Unit) { } /// The panel's flyout menu: the link commands for the selected rows, Select Linked / Embedded / -/// Missing / Modified for the whole document, and the Show, Sort and thumbnail options. +/// Missing / Modified for the whole document, the Show and Sort options, Copy Info and the +/// thumbnail options. pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let o = options(ui.ctx()); // The rows the panel shows (the menu opens from it). @@ -819,6 +871,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } } ui.separator(); + if menu_item(ui, tl!("Copy Info"), any, false) { + crate::menus::invoke(app, "links.copyInfo", json!({ "ids": ids(&sel, |_| true) })); + } + ui.separator(); for (px, label) in THUMBS { if menu_item(ui, tl!(label), true, o.thumb == px) { set_options(ui.ctx(), |o| o.thumb = px); diff --git a/crates/ui-egui/src/panels/opentype.rs b/crates/ui-egui/src/panels/opentype.rs index 2d872872ff..5db4cfbd17 100644 --- a/crates/ui-egui/src/panels/opentype.rs +++ b/crates/ui-egui/src/panels/opentype.rs @@ -1,6 +1,7 @@ //! OpenType panel: per-character OpenType features (ligatures, alternates, swashes, small caps, -//! ordinals, fractions, figure style, position) for the selected text (every frame of each -//! selected threaded story) or the Type tool's selected characters. +//! ordinals, fractions, figure style, position, and with the East Asian options the menu's H or V +//! Style and Roman Italics) for the selected text (every frame of each selected threaded story) or +//! the Type tool's selected characters. use egui::Ui; use serde_json::json; @@ -117,10 +118,42 @@ fn shown_figure(app: &VectorcraftApp) -> Option { shared(app, |s| figure(&OtFeatures::from_tags(s.features.iter().map(String::as_str)))) } -/// Whether `flag` is on for every character the panel acts on (`Some(true)`), for none of them -/// (`Some(false)`), or only for some (`None`). +/// Whether `flag` is on for every character a toggle changes (`Some(true)`), for none of them +/// (`Some(false)`), or only for some (`None`). At a Type tool caret, and after Select All in a +/// threaded story, the command styles all the text ([`run_features`]), so every character of it +/// counts, as in [`edit`]. fn shown_flag(app: &VectorcraftApp, flag: Flag) -> Option { - shared(app, |s| flag.get(&OtFeatures::from_tags(s.features.iter().map(String::as_str)))) + let on = |s: &CharStyle| flag.get(&OtFeatures::from_tags(s.features.iter().map(String::as_str))); + if let Some(mut runs) = whole_text_runs(app) { + // A run with no text, such as an empty frame's, counts only when no run has text. + if runs.iter().any(|r| !r.text.is_empty()) { + runs.retain(|r| !r.text.is_empty()); + } + let Some(first) = runs.first() else { + return shared(app, on); + }; + let v = on(&first.style); + return runs.iter().all(|r| on(&r.style) == v).then_some(v); + } + shared(app, on) +} + +/// The runs the panel's commands style as a whole while the Type tool edits text: those of every +/// frame of the story after Select All in threaded text, else at a caret (an empty selection) +/// those of the text object. `None` for a range in one text object, or with no Type tool editing. +fn whole_text_runs(app: &VectorcraftApp) -> Option> { + let (id, a, b) = text_editing(app)?; + let st = app.session.active()?; + let ids = match super::character::text_story(app) { + Some(story) => story, + None if b <= a => vec![id], + None => return None, + }; + let runs = ids.into_iter().filter_map(|id| match &st.doc.node(id)?.kind { + NodeKind::Text(t) => Some(t.runs.iter()), + _ => None, + }); + Some(runs.flatten().collect()) } /// The Figure menu's text for entry `shown`: Mixed when the characters differ. @@ -150,8 +183,9 @@ impl Change { } /// Make `change` to the features of each character the panel acts on, from the character's own -/// features (`featureChanges`), unless it changes none of them. At a Type tool caret the -/// command styles all the text, so every character of the text is checked. +/// features (`featureChanges`), unless it changes none of them. At a Type tool caret, and after +/// Select All in a threaded story, the command styles all the text, so every character of it is +/// checked ([`whole_text_runs`]). fn edit(app: &mut VectorcraftApp, change: Change) { let same = |s: &CharStyle| { let before = OtFeatures::from_tags(s.features.iter().map(String::as_str)); @@ -159,12 +193,9 @@ fn edit(app: &mut VectorcraftApp, change: Change) { change.apply(&mut after); after == before }; - let unchanged = match text_editing(app) { - Some((id, a, b)) if b <= a => match app.session.active().and_then(|st| st.doc.node(id)).map(|n| &n.kind) { - Some(NodeKind::Text(t)) => Some(t.runs.iter().all(|r| same(&r.style))), - _ => None, - }, - _ => shared(app, same), + let unchanged = match whole_text_runs(app) { + Some(runs) => Some(runs.iter().all(|r| same(&r.style))), + None => shared(app, same), }; if unchanged != Some(true) { run_features(app, "featureChanges", OtFeatures::changes(|f| change.apply(f))); @@ -304,10 +335,29 @@ fn rows() -> [Toggle; 9] { ] } +/// The panel menu's East Asian items, with Show East Asian Options: label and the feature each +/// turns on and off. H or V Style sets kana in the forms the font draws for the writing direction +/// (`hkna`, shaped as `vkna` in vertical type), and Roman Italics sets half-width alphanumerics +/// in the font's italic forms (`ital`). +fn east_asian_items() -> [Toggle; 2] { + [(tl!("H or V Style"), Flag::Extra("hkna")), (tl!("Roman Italics"), Flag::Extra("ital"))] +} + pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { - if menu_item(ui, tl!("Reset OpenType Features"), text_style(app).is_some(), false) { + let has = text_style(app).is_some(); + if menu_item(ui, tl!("Reset OpenType Features"), has, false) { set(app, OtFeatures::default()); } + if app.session.prefs.show_east_asian_options { + ui.separator(); + // Checked when every character it changes has it; chosen then, it turns it off. + for (label, flag) in east_asian_items() { + let on = shown_flag(app, flag) == Some(true); + if menu_item(ui, label, has, on) { + edit(app, Change::Toggle(flag, !on)); + } + } + } } #[cfg(test)] @@ -715,4 +765,127 @@ mod tests { edit(&mut app, Change::Position(4)); assert_eq!(undo_steps(&app), undo + 1); } + + /// H or V Style and Roman Italics are in the panel menu with the East Asian options only. + #[test] + fn kana_style_and_roman_italics_show_with_the_east_asian_options() { + let mut app = selected_text(); + let shown = |app: &mut VectorcraftApp| { + let text = crate::tests_labels::painted_text(app, menu); + (text.contains("H or V Style"), text.contains("Roman Italics")) + }; + assert_eq!(shown(&mut app), (false, false)); + app.session.prefs.show_east_asian_options = true; + assert_eq!(shown(&mut app), (true, true)); + } + + /// With the Type tool's characters only partly set, H or V Style and Roman Italics show + /// unchecked; chosen, each turns its feature on for every selected character in one undo step, + /// each run keeping its other features, and shows checked; chosen again, it turns it off. + /// Reset OpenType Features clears both. + #[test] + fn kana_style_and_roman_italics_turn_on_and_off() { + for (label, tag) in [("H or V Style", "hkna"), ("Roman Italics", "ital")] { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.prefs.show_east_asian_options = true; + app.session.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "かなAB"})).unwrap()["id"].clone(); + app.session.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 3, "features": [tag, "smcp"]})).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + // "かなAB" is 8 bytes. + app.session.set_tool_option("select", &json!({"start": 0, "end": 8})); + let ctx = egui::Context::default(); + let row = |app: &mut VectorcraftApp, label: &str| { + let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], menu); + texts.into_iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == label).unwrap_or_else(|| panic!("{label} missing")) + }; + let has = |runs: &[(String, Value)], t: &str| runs.iter().all(|(_, f)| f.as_array().is_some_and(|f| f.iter().any(|x| x == t))); + let (text, at) = row(&mut app, label); + assert!(!text.starts_with('✓'), "{label}: {text:?}"); + let undo = undo_steps(&app); + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert_eq!(undo_steps(&app), undo + 1, "{label}"); + let runs = run_features(&mut app, &id, 8); + assert!(has(&runs, tag), "{label}: {runs:?}"); + assert!(has(&runs[..1], "smcp") && runs[0].0 == "か", "{label} keeps the other features: {runs:?}"); + let (text, at) = row(&mut app, label); + assert!(text.starts_with('✓'), "{label}: {text:?}"); + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert_eq!(run_features(&mut app, &id, 8), [("か".to_string(), json!(["smcp"])), ("なAB".to_string(), Value::Null)], "{label} off"); + app.session.execute("text.setRangeStyle", &json!({"id": id, "features": ["hkna", "ital", "dlig"]})).unwrap(); + let at = row(&mut app, "Reset OpenType Features").1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert_eq!(run_features(&mut app, &id, 8), [("かなAB".to_string(), Value::Null)], "{label}: reset"); + } + } + + /// At a Type tool caret after a character that has H or V Style or Roman Italics while the + /// rest of the text does not, the item and the checkbox rows read every character the command + /// styles at a caret (all of them): the item shows unchecked, a Small Caps row set the same way + /// shows a dash, and choosing the item turns its feature on for the whole text. + #[test] + fn caret_items_read_every_character_they_change() { + for (label, tag) in [("H or V Style", "hkna"), ("Roman Italics", "ital")] { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.prefs.show_east_asian_options = true; + app.session.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "かなAB"})).unwrap()["id"].clone(); + app.session.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 3, "features": [tag, "smcp"]})).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + // The caret after "か", whose style the next typed character takes. + app.session.set_tool_option("select", &json!({"start": 3, "end": 3})); + assert_eq!(text_editing(&app).map(|(_, a, b)| (a, b)), Some((3, 3))); + assert_eq!(shown_flag(&app, Flag::Field(|f| &mut f.small_caps)), None, "{label}: Small Caps row"); + let ctx = egui::Context::default(); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); + let Some((text, at)) = texts.into_iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == label) else { panic!("{label} missing") }; + assert!(!text.starts_with('✓'), "{label}: {text:?}"); + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + let runs = run_features(&mut app, &id, 8); + assert!(runs.iter().all(|(_, f)| f.as_array().is_some_and(|f| f.iter().any(|x| x == tag))), "{label}: {runs:?}"); + } + } + + /// After Select All in a threaded story whose edited frame is empty, the item reads the text + /// of every frame the command styles: with the story's text set with H or V Style or Roman + /// Italics it shows checked, and choosing it turns the feature off for that text. + #[test] + fn select_all_from_an_empty_frame_reads_the_whole_story() { + for (label, tag) in [("H or V Style", "hkna"), ("Roman Italics", "ital")] { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.prefs.show_east_asian_options = true; + app.run("file.new", json!({"width": 600, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame. ".repeat(4); + let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}}); + let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap(); + let b = app.run("shape.rectangle", json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + app.run("text.thread.create", json!({})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + app.session.set_tool_option("textSelection", &json!({"editing": a, "anchor": 0, "caret": 0})); + app.run("select.all", json!({})).unwrap(); + // A frame threaded in before the first takes the whole text, leaving the edited frame empty. + let c = app.run("text.thread.add", json!({"from": a, "before": true, "rect": [700, 10, 400, 600]})).unwrap()["id"].as_u64().unwrap(); + app.run("text.setRangeStyle", json!({"id": c, "features": [tag]})).unwrap(); + assert_eq!(text_editing(&app).map(|(id, a, b)| (id.0, a, b)), Some((a, 0, 0)), "{label}"); + assert_eq!(super::super::character::text_story(&app).map(|frames| frames.len()), Some(3), "{label}"); + // The empty frames' runs, which hold no character, without the feature. + for id in [a, b] { + let doc = std::sync::Arc::make_mut(&mut app.session.doc_mut().unwrap().doc); + let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("no frame {id}") }; + for r in &mut t.runs { + r.style.features.clear(); + } + } + let ctx = egui::Context::default(); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); + let Some((text, at)) = texts.into_iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == label) else { panic!("{label} missing") }; + assert!(text.starts_with('✓'), "{label}: {text:?}"); + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + let runs = run_features(&mut app, &json!(c), story.len()); + assert!(runs.iter().all(|(_, f)| !f.as_array().is_some_and(|f| f.iter().any(|x| x == tag))), "{label}: {runs:?}"); + } + } } diff --git a/crates/ui-egui/src/panels/swatches/actions.rs b/crates/ui-egui/src/panels/swatches/actions.rs index 06c8935d30..b183787366 100644 --- a/crates/ui-egui/src/panels/swatches/actions.rs +++ b/crates/ui-egui/src/panels/swatches/actions.rs @@ -121,7 +121,7 @@ pub(super) fn new_swatch(app: &mut VectorcraftApp, sel: &[String], m: egui::Modi } pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { - let view: View = pstate(ui.ctx(), "swatch-view"); + let view = app.ui.swatches_view; let selected = selection(app, ui); let del = deletable(&selected); let opts = editable(app, &selected); @@ -174,7 +174,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { ui.separator(); for (v, label) in View::ALL { if menu_item(ui, label, true, v == view) { - set_pstate(ui.ctx(), "swatch-view", v); + app.run("ui.panelView", json!({ "panel": "swatches", "view": v })).ok(); } } ui.separator(); diff --git a/crates/ui-egui/src/panels/swatches/body.rs b/crates/ui-egui/src/panels/swatches/body.rs index ef830c8c43..96e003bcf1 100644 --- a/crates/ui-egui/src/panels/swatches/body.rs +++ b/crates/ui-egui/src/panels/swatches/body.rs @@ -16,17 +16,21 @@ pub(super) fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) { crate::panels::empty_state(ui, "swatch-book", tl!("No document"), tl!("Open a document to see its swatches.")); return; } - let view: View = pstate(ui.ctx(), "swatch-view"); + let view = app.ui.swatches_view; let kind: Kind = pstate(ui.ctx(), "swatch-kind"); // Top row: proxy at left, list / grid view toggles at right. ui.horizontal(|ui| { crate::panels::proxy(app, ui, 34.0); ui.add_space(ui.available_width() - 58.0); + let mut to = None; if widgets::icon_button(ui, "dc-list-view", tl!("Show List View"), view.is_list(), 26.0).clicked() { - set_pstate(ui.ctx(), "swatch-view", View::SmallList); + to = Some(View::SmallList); } if widgets::icon_button(ui, "dc-grid-view", tl!("Show Thumbnail View"), !view.is_list(), 26.0).clicked() { - set_pstate(ui.ctx(), "swatch-view", View::MediumThumb); + to = Some(View::MediumThumb); + } + if let Some(v) = to { + app.run("ui.panelView", json!({ "panel": "swatches", "view": v })).ok(); } }); ui.add_space(4.0); diff --git a/crates/ui-egui/src/panels/swatches/tests.rs b/crates/ui-egui/src/panels/swatches/tests.rs index 2de0dd2a3d..601c8dc627 100644 --- a/crates/ui-egui/src/panels/swatches/tests.rs +++ b/crates/ui-egui/src/panels/swatches/tests.rs @@ -489,7 +489,7 @@ fn find_field_filters_and_shift_extends_over_what_is_shown() { fn list_view_rows_take_double_clicks_and_folder_clicks_select_the_group() { let mut app = app(); let ctx = context(); - set_pstate(&ctx, "swatch-view", View::SmallList); + app.ui.swatches_view = View::SmallList; double_click_tile(&mut app, &ctx, "Red"); assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND), "double-click in a list opens Swatch Options"); let d = &app.session.active().unwrap().doc; @@ -1013,7 +1013,7 @@ fn the_list_fills_a_tall_panel_above_its_footer() { app.run("swatch.new", json!({"name": format!("Tone {i:02}"), "color": format!("#{:02x}3060", i * 4)})).unwrap(); } let ctx = context(); - set_pstate(&ctx, "swatch-view", View::SmallList); + app.ui.swatches_view = View::SmallList; // The clip rect of the first swatch row's name: the list's visible area. let mut list = |bottom: Option| { let mut clip = None; diff --git a/crates/ui-egui/src/panels/symbols.rs b/crates/ui-egui/src/panels/symbols.rs index c0abeb9dac..195d6172f4 100644 --- a/crates/ui-egui/src/panels/symbols.rs +++ b/crates/ui-egui/src/panels/symbols.rs @@ -286,7 +286,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some((doc, keys)) = app.session.active().map(|d| (d.doc.clone(), document_symbols(d))) else { return }; let names: Vec = doc.symbols.iter().map(|s| s.name.clone()).collect(); - let view: View = pstate(ui.ctx(), VIEW); + let view = app.ui.symbols_view; let sel = current(app); let uid = app.session.active().map(|d| d.uid); let marked = marked(ui.ctx(), uid, &names); @@ -458,10 +458,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { app.status(e); } ui.separator(); - let view: View = pstate(ui.ctx(), VIEW); + let view = app.ui.symbols_view; for (v, label) in View::ALL { - if menu_item(ui, tl!(label), true, view == v) { - set_pstate(ui.ctx(), VIEW, v); + if menu_item(ui, tl!(label), true, view == v) + && let Err(e) = app.run("ui.panelView", json!({ "panel": "symbols", "view": v })) + { + app.status(e); } } ui.separator(); @@ -475,13 +477,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { /// The panel state key of the symbols Select All Unused picked. const MARKED: &str = "sym-unused"; -/// The panel state key of the panel's [`View`]. -const VIEW: &str = "sym-view"; - /// The panel's views: Thumbnail View (the default) and the Small and Large List Views, whose rows -/// show each symbol's thumbnail and name. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -enum View { +/// show each symbol's thumbnail and name (`ui.panelView {panel: "symbols", view}`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum View { #[default] Thumbnail, SmallList, @@ -1170,7 +1170,7 @@ mod tests { // Large List View: the row named Dot. let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); - set_pstate(&ctx, VIEW, View::LargeList); + app.ui.symbols_view = View::LargeList; let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 400.0))), ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui)); out.textures_delta.clear(); @@ -1238,7 +1238,6 @@ mod tests { assert_eq!((editing(&app), current(&mut app)), (None, Some("Cat".into())), "{view}"); let ctx2 = egui::Context::default(); crate::theme::install_fonts(&ctx2); - set_pstate(&ctx2, VIEW, pstate::(&ctx, VIEW)); frame(&ctx2, &mut app, vec![]); frame(&ctx2, &mut app, double_click(row)); assert_eq!(editing(&app).as_deref(), Some("Cat"), "{view}"); @@ -1253,6 +1252,49 @@ mod tests { assert_eq!(app.session.active().unwrap().history.undo.len(), undo); } + /// `ui.panelView` sets the Symbols, Graphic Styles and Swatches panels' views as the view items + /// of their panel menus do, and answers the current view without `view`. An unknown panel or + /// view, or a view of another panel, is an error and changes nothing. + #[test] + fn panel_view_sets_the_view_of_each_panel() { + use super::super::{graphic_styles, swatches}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + for (i, name) in ["ant", "bee"].into_iter().enumerate() { + app.run("shape.rectangle", json!({"x": 10.0 + 40.0 * i as f64, "y": 10, "width": 30, "height": 20})).unwrap(); + app.run("symbol.new", json!({"name": name})).unwrap(); + } + let views = |checked: &str| ["Thumbnail View", "Small List View", "Large List View"].map(|v| (v.to_string(), v == checked)).to_vec(); + assert_eq!(app.run("ui.panelView", json!({"panel": "symbols"})).unwrap(), json!({"panel": "symbols", "view": "thumbnail"})); + assert!(panel_labels(&ctx, &mut app, &["ant", "bee"]).is_empty(), "the thumbnails draw no names"); + let r = app.run("ui.panelView", json!({"panel": "symbols", "view": "largeList"})).unwrap(); + assert_eq!(r, json!({"panel": "symbols", "view": "largeList"})); + assert_eq!(view_items(&ctx, &mut app), views("Large List View"), "the panel menu checks it"); + let names: Vec = panel_labels(&ctx, &mut app, &["ant", "bee"]).into_iter().map(|(t, _)| t).collect(); + assert_eq!(names, ["ant", "bee"], "the list names the symbols"); + click_menu(&ctx, &mut app, "Small List View"); + assert_eq!(app.run("ui.panelView", json!({"panel": "symbols"})).unwrap()["view"], "smallList", "the menu item sets it too"); + for bad in [ + json!({"panel": "symbols", "view": "mediumThumb"}), + json!({"panel": "symbols", "view": 3}), + json!({"panel": "brushes", "view": "smallList"}), + json!({"view": "largeList"}), + ] { + assert!(app.run("ui.panelView", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(app.ui.symbols_view, View::SmallList, "unchanged"); + let r = app.run("ui.panelView", json!({"panel": "graphicStyles", "view": "largeList"})).unwrap(); + assert_eq!(r, json!({"panel": "graphicStyles", "view": "largeList"})); + assert_eq!(app.ui.graphic_styles_view, graphic_styles::View::LargeList); + assert_eq!(app.run("ui.panelView", json!({"panel": "swatches"})).unwrap()["view"], "mediumThumb"); + app.run("ui.panelView", json!({"panel": "swatches", "view": "largeThumb"})).unwrap(); + assert!(app.run("ui.panelView", json!({"panel": "swatches", "view": "thumbnail"})).is_err()); + assert_eq!(app.ui.swatches_view, swatches::View::LargeThumb, "unchanged by the error"); + assert_eq!(app.ui.symbols_view, View::SmallList, "each panel keeps its own view"); + } + /// The panel menu's Edit Symbol edits the selected instance's symbol, and with no instance /// selected the symbol current in the panel. #[test] @@ -1724,7 +1766,7 @@ mod tests { assert!(!symbols(h.state_mut()).contains(&"B".to_string())); // Also test list view - set_pstate(&h.ctx, VIEW, View::SmallList); + h.state_mut().ui.symbols_view = View::SmallList; h.run_steps(2); let node_a = h.get_by_label("A"); let pos_a = node_a.rect().center(); diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index 7184135448..2771fe568e 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -104,6 +104,42 @@ fn the_panel_lists_the_images_with_link_info_for_the_selected_one() { } } +/// Copy Info copies the file names of the selected links, or of `ids` in the order given, one per +/// line; it is off with nothing selected, and a bad `ids` leaves the clipboard as it was. +#[test] +fn copy_info_copies_the_file_names_of_the_links() { + let dir = Folder::new("copy-info"); + let (mut app, linked, embedded) = app(&dir); + let menu = painted_text(&mut app, super::links::menu); + assert!(menu.contains("Copy Info"), "{menu}"); + // The selection is the embedded image: its row's name. + assert!(crate::menus::enabled(&app, "links.copyInfo")); + let r = app.run("links.copyInfo", json!({})).unwrap(); + assert_eq!(r["names"], json!(["embedded.png"])); + assert_eq!(app.clipboard_out.take().as_deref(), Some("embedded.png")); + // Both selected: in the order the list gives them (the top image first). + app.run("select.set", json!({"ids": [linked, embedded]})).unwrap(); + assert_eq!(app.run("links.copyInfo", json!({})).unwrap()["text"], "embedded.png\nlinked.png"); + assert_eq!(app.clipboard_out.take().as_deref(), Some("embedded.png\nlinked.png")); + // `ids` in the order given, whatever is selected. + app.run("select.none", json!({})).unwrap(); + let r = app.run("links.copyInfo", json!({"ids": [linked, embedded]})).unwrap(); + assert_eq!(r["names"], json!(["linked.png", "embedded.png"])); + assert_eq!(app.clipboard_out.take().as_deref(), Some("linked.png\nembedded.png")); + // Errors copy nothing. + let rect = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap(); + app.run("select.none", json!({})).unwrap(); + for bad in + [json!({"ids": [linked, rect]}), json!({"ids": [linked, 999_999]}), json!({"ids": "all"}), json!({"ids": [linked, -1]}), json!({"ids": []})] + { + assert!(app.run("links.copyInfo", bad.clone()).is_err(), "{bad}"); + assert!(app.clipboard_out.is_none(), "{bad}"); + } + assert!(!crate::menus::enabled(&app, "links.copyInfo")); + assert!(app.run("links.copyInfo", json!({})).is_err()); + assert!(app.clipboard_out.is_none()); +} + /// Shift-click selects the images of the rows from the last row clicked without Shift; Ctrl/Cmd-click /// adds or removes one image, and Ctrl/Cmd-Shift-click adds the rows' images to the selection. #[test] @@ -168,6 +204,11 @@ fn a_link_inside_a_symbol_is_listed_and_picked_in_the_panel() { click_row(&mut app, &ctx, "inside.png", egui::Modifiers::NONE, 1.0); assert!(selected(&app).is_empty(), "the canvas can't select it"); assert!(shown(&mut app, 1.5).iter().any(|t| t == "30 × 20 px"), "Link Info of the symbol's image"); + // Copy Info without ids copies the row picked in the panel. + app.ui_ctx = Some(ctx.clone()); + assert!(crate::menus::enabled(&app, "links.copyInfo")); + assert_eq!(app.run("links.copyInfo", json!({})).unwrap()["text"], "inside.png"); + assert_eq!(app.clipboard_out.take().as_deref(), Some("inside.png")); click_row(&mut app, &ctx, "layer.png", egui::Modifiers::COMMAND, 2.0); assert_eq!(selected(&app), [layer]); // Both rows are selected until the canvas selection changes: then the panel's pick goes too. diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b28e4034a7..8c9956b474 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -838,6 +838,14 @@ pub struct UiState { /// Search All text does. #[serde(skip)] pub layers_filter: crate::panels::layers::Filter, + /// The Swatches, Graphic Styles and Symbols panels' views, chosen in their panel menus + /// (`ui.panelView`). They are not saved and last the session. + #[serde(skip)] + pub swatches_view: crate::panels::swatches::View, + #[serde(skip)] + pub graphic_styles_view: crate::panels::graphic_styles::View, + #[serde(skip)] + pub symbols_view: crate::panels::symbols::View, /// The Image Trace panel's Advanced section is open (as it was last left). #[serde(default = "yes")] pub image_trace_advanced: bool, @@ -1242,6 +1250,9 @@ impl Default for UiState { layers_panel: Default::default(), navigator_panel: Default::default(), layers_filter: Default::default(), + swatches_view: Default::default(), + graphic_styles_view: Default::default(), + symbols_view: Default::default(), image_trace_advanced: true, image_trace_preview: true, glyphs_preview: 0, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e3e99e67ed..e6f3621e8e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -96,13 +96,13 @@ the two can still win that race. | `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item | | `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected. With the Artboard tool it lists the artboard entries (see `docs/mcp.md`), after selecting the artboard there unless it is already selected. While a menu opened over locked objects is open, it has an `Unlock` submenu with one item for each locked object under the pointer, topmost first and at most 20, labeled with the object's name, which runs `layer.setProps {ids: [id], locked: false}`. A locked group gets one item, and the locked objects inside it get none. Hidden objects, objects on hidden, locked or template layers, and in isolation mode objects outside the isolated group get no item | | `ui.tool.select` / `ui.tool.list` | `{tool}` | | -| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse: `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was; the reply lists `selection`, `key` (the key object, null without one) and `tool`. With the Shape Builder or the Live Paint Bucket and a selection of more than 128 segments, the app finds the regions off the UI thread, so a `move` replies before the region under the pointer is highlighted, and the highlight shows in the first frame after the regions are found. With the Shape Builder and such a selection, the app also merges off the UI thread: the reply to the events that end a click or drag comes once the merge is applied. A click or drag made before the regions are found does not wait for them; the app records it and makes it once they are found. Until then the canvas keeps drawing, with the touched regions highlighted and the busy pointer. A click or drag sent meanwhile, with `mods.cmd` too, and the Shape Builder's arrow keys are made after the merge in the order sent, and the reply to such a request comes once its events are made and any merge they start is applied; a request of `move` events alone is answered at once. A command that can edit, select, save or redraw the documents (an edit, an undo, a selection, a save or an export, a tool option, another tool, another document, closing, a dialog's preview, a menu item, a command of the app's own such as `ui.recolorDialog`, the proof and separations views, a preference, added fonts) waits for the merge and the input sent before it, and runs after them; queries that only read, such as `document.inspect`, run at once on the document before the merge | +| `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, space?: "doc"\|"screen", mods?}]}` | drive the active tool exactly like the mouse (`doubleclick` follows the second click's `up`, and `tripleclick` the third's): `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was; the reply lists `selection`, `key` (the key object, null without one) and `tool`. With the Shape Builder or the Live Paint Bucket and a selection of more than 128 segments, the app finds the regions off the UI thread, so a `move` replies before the region under the pointer is highlighted, and the highlight shows in the first frame after the regions are found. With the Shape Builder and such a selection, the app also merges off the UI thread: the reply to the events that end a click or drag comes once the merge is applied. A click or drag made before the regions are found does not wait for them; the app records it and makes it once they are found. Until then the canvas keeps drawing, with the touched regions highlighted and the busy pointer. A click or drag sent meanwhile, with `mods.cmd` too, and the Shape Builder's arrow keys are made after the merge in the order sent, and the reply to such a request comes once its events are made and any merge they start is applied; a request of `move` events alone is answered at once. A command that can edit, select, save or redraw the documents (an edit, an undo, a selection, a save or an export, a tool option, another tool, another document, closing, a dialog's preview, a menu item, a command of the app's own such as `ui.recolorDialog`, the proof and separations views, a preference, added fonts) waits for the merge and the input sent before it, and runs after them; queries that only read, such as `document.inspect`, run at once on the document before the merge | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input | | `ui.move` | `{x, y}` | screen-point pointer move | | `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above | | `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls, Cmd-wheel (Ctrl on Windows and Linux) scrolls sideways and Alt-wheel (Option on the Mac) zooms about the pointer (with the `ctrlWheelScrollsSideways` preference off, Cmd-wheel zooms too); with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it. Over the canvas, the wheel preferences in `docs/mcp.md` change what these keys do | | `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `outline`: View › Outline for the active document only (each document has its own, a document opens in Preview, and `ui.inspect` shows it as `view.outline`; an error without a document). `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) | -| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`) other than its `__` fields, the `__` fields that are controls of the dialog (such as `__section` and `__allArtboards` in Save PDF, `__constrain` in Transform Each and the shape dialogs, and `__search` and `__category` in Preferences; Save PDF's and Print's `__savePresetAs`, the preset name, only while Save Preset… asks for it, since OK then saves a preset), `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was. Any other `__` field, held or not, holds what the dialog's OK acts on (such as the `__command` and `__params` of a `confirm` or `command` dialog) or the dialog's own state: setting it is the error "the `` dialog cannot set protected field ``", and the dialog stays as it was. `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes | +| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`) other than its `__` fields, the `__` fields that are controls of the dialog (such as `__section` and `__allArtboards` in Save PDF, `__constrain` in Transform Each and the shape dialogs, and `__search` and `__category` in Preferences; Save PDF's and Print's `__savePresetAs`, the preset name, only while Save Preset… asks for it, since OK then saves a preset), `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass, Texturizer and Rough Pastels effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was. Any other `__` field, held or not, holds what the dialog's OK acts on (such as the `__command` and `__params` of a `confirm` or `command` dialog) or the dialog's own state: setting it is the error "the `` dialog cannot set protected field ``", and the dialog stays as it was. `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes | | `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index bf52925f5c..06405fa620 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -245,7 +245,8 @@ private editing data. VectorCraft: group is tagged once and paints its marks with one fill and one stroke, which the graph keeps as that series' paint; Graph Data then edits it. Other graphs (pie and scatter graphs, graphs with designs or customizations, records of later versions) come in as plain groups, paths and type, with a note. Saving a graph to - `.ai`, in either form, writes its art without its data or settings, with a warning. + `.ai`, in either form, writes these records around its art when the reader above reads them back as the same graph, + so the file opens again with its data and settings; any other graph is written as its art, with a warning. Legacy `.ai` patterns in the version 7 form (a list of `@` and `&` layers, `_ &` for the tile's background) open as pattern swatches, with a fill's offset, scale, rotation, reflection and shear applied before its matrix. Legacy `.ai` linked images in @@ -290,8 +291,8 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | yes (`.plt`: each visible path once along its line, a pen per color or a pen table, type as outlines, clipping applied, HP-GL or HP-GL/2 with pen colors and encoded polylines) | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `crates/cad/src/hpgl/write/tests.rs` (plots read back by the reader), `tests_hpgl.rs`, `import_fuzz.rs` | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | -| PSD | open, place, export | yes (open: layers as layers, or the merged image with `layers: false`; place: merged image; every mode and depth; `.psd` and `.pdd`) | yes (layered, max editability) | placing as layers and a PSD import options dialog missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | -| PSB | open | yes (layers as layers, or the merged image) | no | | +| PSD | open, place, export | yes (open: layers as layers, or the merged image with `layers: false`; place: merged image; the merged image comes in clipped by the file's clipping path; every mode and depth; `.psd` and `.pdd`) | yes (layered, max editability) | placing as layers and a PSD import options dialog missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | +| PSB | open | yes (layers as layers, or the merged image; the merged image comes in clipped by the file's clipping path) | no | | | PNG / JPEG / GIF / BMP / TIFF / Targa | open, place, export | yes (PNG: `.png` and `.pns`; BMP: `.bmp`, `.dib` and `.rle`; Targa: `.tga`, `.vda`, `.icb` and `.vst`, raw or run-length encoded, with a 16-bit file's one-bit alpha) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa: `tests_tiffbmp.rs`, `tests/links.rs`; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), and so do files that store each channel as its own plane (PlanarConfiguration 2), CMYK ones with their ink amounts, and 1-bit files compressed with CCITT Group 3 (Compression 2 and 3, one- and two-dimensional rows); the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | | WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | | JPEG XL `.jxl` | — | yes (first frame; 16-bit and floating-point samples as 8 bits, alpha kept; CMYK refused) | no | decoded with jxl-oxide; the size is checked from the header before decoding; container Exif resolution sizes opened and placed images; tests in `crates/engine/src/cmd/fileio/tests_jxlread.rs` and `crates/engine/tests/import_fuzz.rs` | diff --git a/docs/gaps.md b/docs/gaps.md index 5e31ddee13..d0140731e2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -522,6 +522,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G2 Photoshop-style raster effects and the Effect Gallery - **Done:** Effect › Texture › Texturizer › Load Texture… (stored grayscale `textureImage`, Loaded Texture in the Texture menu), matching Glass. +- **Done:** Effect › Artistic › Rough Pastels › Load Texture… ([raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html)): the image's gray levels are the paper surface, stored with the effect, listed as Loaded Texture in the Texture menu, with Scaling, Relief, Light Direction and Invert acting on it. - **Done:** the Effect Gallery's filter thumbnails and in-dialog preview ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). Each thumbnail is an original landscape drawing passed through that filter at its defaults; the preview area shows the same drawing through the visible stack, or through the filter being browsed when the stack is empty. The canvas preview and the one-undo OK are unchanged (`dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`). All 57 Photoshop-style filters are in the catalogue. Measured in [effects-parity.md](effects-parity.md). - **Done:** the Effect Gallery (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step (`dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`). diff --git a/docs/localization-hi.md b/docs/localization-hi.md index d73e78676b..743a1697ec 100644 --- a/docs/localization-hi.md +++ b/docs/localization-hi.md @@ -32,6 +32,13 @@ every clause such as "all" or "what follows it". Type as a text object is टा in `crates/ui-egui/src/i18n/tests.rs` checks the save, swatch, crop, resolution and path terms and the point unit in every row. +Translate a label by its sense in the interface, not by its first dictionary sense. A ruler is +रूलर, never शासक (a sovereign); a text character is कैरेक्टर, never चरित्र (a personality); a +vector point is पॉइंट and a printer's mark is मार्क, never अंक (a digit); fills are फ़िल्स, never +भरता (the verb). In a minimum, desired and maximum range, Desired is वांछित, never देसी (local), +and a range message keeps both comparisons: minimum ≤ desired ≤ maximum. The same test rejects +these senses, and others found in the catalog, in every row whose English names the word. + A variable is वेरिएबल (plural वेरिएबल्स) in every sense: a data-merge variable and the variable library, a variable font, a variable stroke width and an environment variable. It is never चर, परिवर्तनीय or वैरिएबल. Joining paths, anchor points, point type or the lines of a @@ -61,7 +68,10 @@ checks every `@msg` row's lists. | Artwork | आर्टवर्क | | Path / Anchor point / Handle | पाथ / एंकर पॉइंट / हैंडल | | File path on disk | फ़ाइल पाथ | -| Fill / Stroke | फ़िल / स्ट्रोक | +| Fill / Fills / Stroke | फ़िल / फ़िल्स / स्ट्रोक | +| Point (vector) / Printer's marks | पॉइंट / मार्क्स | +| Ruler / Ruler origin | रूलर / रूलर ओरिजिन | +| Minimum / Desired / Maximum | न्यूनतम / वांछित / अधिकतम | | Gradient / Mesh / Blend | ग्रेडिएंट / मेश / ब्लेंड | | Pattern / Swatch | पैटर्न / स्वॉच | | Type (text object) / Crop / Resolution | टाइप / क्रॉप / रिज़ॉल्यूशन | @@ -89,7 +99,10 @@ percentage of the application's entire interface. On 2026-11-13 the catalog has 8,782 rows, including 3,014 `@msg`, 47 `@msgplural` and 41 `@plural` rows. A wording pass on that date corrected 193 rows outside the plug-in messages for meaning, dropped clauses, English left in place and the save, swatch, crop, -resolution, path and type terms. It is not a native proofread. +resolution, path and type terms. A second pass on that date corrected 85 short labels and +messages that used a word's unrelated sense, such as देसी (local) for Desired, शासक (a +sovereign) for a ruler and उच्च न्यायालय (a high court) for High Contrast. It is not a native +proofread. The comparison covers release `v0.21.1` (`f6aa4309`) and current `main` (`3c7d9e1f`). Their non-English catalogs have no missing Hindi keys except the names deliberately kept in English. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index a83f7b09c2..2fa44a769e 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -31,7 +31,7 @@ Chinese by `imachus`). Georgian and the original Gujarati catalog are recorded a | English | `en` | source, 100% | all | Latin | — | full | — | | Simplified Chinese (Mandarin) | `zh-hans` | 5,102 rows, 791 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted | all (3,554 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 55 missing messages for it, which open #7116, #7211, #7330, #7378 and #7405 translate) | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | full | — (review) | | Spanish | `es` | 4,009 / 4,020 (99.7%) | all (604 messages) | Latin | no | full | — (review) | -| Hindi | `hi` | 8,782 rows (2026-11-13): 3,014 `@msg`, 47 `@msgplural`, 41 `@plural`; the untranslated report on main `f793044f83` (2026-11-14) lists 676 missing rows for it: 608 in its own section and 68 that every checked language lacks | menus, dialogs, tooltips and scanned messages; contributor-approved loanword terminology; agent wording pass | UI and document text shape with HarfRust; installed desktop Devanagari fallback, or a `Deva` craft-fonts face; web font still needed | contributor terminology review; 2026-11-13 wording pass on messages and longer labels, with a glossary-term test; wider proofread welcome | complete catalog, machine-assisted draft | reported missing rows, native review, layout and web font | +| Hindi | `hi` | 8,782 rows (2026-11-13): 3,014 `@msg`, 47 `@msgplural`, 41 `@plural`; the untranslated report on main `f793044f83` (2026-11-14) lists 676 missing rows for it: 608 in its own section and 68 that every checked language lacks | menus, dialogs, tooltips and scanned messages; contributor-approved loanword terminology; agent wording pass | UI and document text shape with HarfRust; installed desktop Devanagari fallback, or a `Deva` craft-fonts face; web font still needed | contributor terminology review; 2026-11-13 wording pass on messages and longer labels, and a word-sense pass on short labels, with a glossary-term test; wider proofread welcome | complete catalog, machine-assisted draft | reported missing rows, native review, layout and web font | | Gujarati | `gu` | 4,673 rows (2026-11-14): original 4,286 unchanged; all 387 additions contributor-reviewed and approved | menus and dialogs; status/error messages partial | installed Gujarati fonts on desktop; optional `Gujr` craft-fonts fallback; no Gujarati face in the pinned CI/release fonts or web startup list | original catalog reviewed by a native speaker; all 387 later additions approved by the contributor, with 11 wording corrections | contributor-reviewed catalog | remaining messages and a bundled Gujarati web font | | Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 | | French | `fr` | 9,166 rows, 3,436 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 178 missing messages for it | all | Latin | no | full | — (review) | diff --git a/docs/mcp.md b/docs/mcp.md index ea2d9f07fd..9336ea35ca 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -647,8 +647,8 @@ sampler. | `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?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], 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, and a `backquote` that is not a boolean is an error. 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. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. | -| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], 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. Remote, the events wait until the keys and other input sent before them (`press_key`, `type_text`) have reached the app, so a key pressed between two calls of one drag applies before the release. When that input is dropped because the document, a dialog or the focus changed first, the call fails and its events are not sent. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. | +| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`, `tripleclick` (`doubleclick` follows the second click's `up`, and `tripleclick` the third's). `backquote` is the held state for each event; the top-level value supplies its default, and a `backquote` that is not a boolean is an error. 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. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. | +| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`, `tripleclick` (`doubleclick` follows the second click's `up`, and `tripleclick` the third's). `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. Remote, the events wait until the keys and other input sent before them (`press_key`, `type_text`) have reached the app, so a key pressed between two calls of one drag applies before the release. When that input is dropped because the document, a dialog or the focus changed first, the call fails and its events are not sent. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. | | `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`. Polygons and stars take `rotation` in degrees clockwise (0: a corner or point straight up). `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. | @@ -3238,6 +3238,13 @@ already walks Image Trace. Short agent entry points: unchanged. No undo step is added when the symbols are already in order. The panel menu's Thumbnail View, Small List View and Large List View change only how the panel shows the symbols: thumbnails, or rows with a thumbnail and the name (smaller rows in Small List View). +- Panel views: `ui.panelView {panel, view?}` sets the view of the Swatches, Graphic Styles or Symbols panel, as the + view items of its panel menu (and the Swatches panel's list and thumbnail buttons) do. `panel` is `swatches`, + `graphicStyles` or `symbols`. The Swatches panel takes `smallThumb`, `mediumThumb` (the default), `largeThumb`, + `smallList` or `largeList`; the Graphic Styles and Symbols panels take `thumbnail` (the default), `smallList` or + `largeList`. Without `view` the command answers the current one. Each panel keeps its own view for the session, and + the document does not change. An unknown panel or view is an error, and then nothing changes. It answers + `{panel, view}`. - The Brushes panel menu's Select All Unused: `brush.unused` (a query) lists the brushes no stroke paints with, in library order, and the panel selects them. Strokes in the art (opacity masks included), symbols, graph designs, pattern tiles (with the tiles that pattern editing's Cancel puts back) and graphic styles count, and so do the @@ -3698,8 +3705,20 @@ already walks Image Trace. Short agent entry points: `ui.imageTracePresetDialog {params?, rename?}` opens the Image Trace panel's Save as New Preset… or Rename… dialog. - Live Paint: `livePaint.make`, `livePaint.merge`, `livePaint.fill {group?, point}`, `livePaint.strokeEdge`, `livePaint.info {group?|id?}`, `livePaint.release`, `livePaint.expand`, and `livePaint.gapOptions {ids?, detect?, size?, - color?, closeGaps?}` (Object › Live Paint › Gap Options). Make gives each covered face the fill of its front-most - covering source, including compound paths with shared curved boundaries. Expand keeps those face fills. + color?, closeGaps?}` (Object › Live Paint › Gap Options). Make gives each covered face the fill rows that draw of + its front-most covering source that has one, including compound paths with shared curved boundaries, and each edge + the stroke rows that draw of its source. A row draws when it is shown and has paint and an opacity above 0; a + stroke also needs a weight above 0, and a source whose opacity is 0 has no rows that draw. The rows keep their order + and settings (opacity, blending mode, effects, overprint, and a stroke's weight, caps, joins, dashes and alignment), + gradients keep their fit on the source, and the face or edge takes the source's opacity and blending mode. Where a + path is cut into several edges, each edge runs the way the path does, its dashes go on from where it starts along + the path (Align Dashes to Corners is turned off on it), the arrowheads stay only at the path's own ends, and a width + profile or brush stays only on an edge that runs the whole path. A gradient applied along a stroke starts again on + each cut edge, and an inside or outside stroke is drawn centered on an edge that is an open piece of a closed path. + When `livePaint.gapOptions` or `livePaint.merge` cuts an edge into pieces, each piece takes the edge's paint, with + its rows fitted to its part of the edge in the same way. A face or edge given no such row has one unpainted row. Rows that do not draw stay with the stored sources, which + `livePaint.release` returns unchanged. The source's object effects and the strokes' pen pressure are not carried. + Expand keeps those face fills. `livePaint.expand` keeps each face and edge that draws, with all its fill and stroke rows: it is shown, its opacity is above 0, and a fill or stroke row is shown, has paint and an opacity above 0 (a stroke also a weight above 0), whichever row is on top. A painted lower row under an unpainted top row keeps the part; a part whose only painted @@ -3772,6 +3791,31 @@ already walks Image Trace. Short agent entry points: `livePaint.strokeEdge` when an edge is within 4 screen pixels of the pointer and `livePaint.fill` otherwise, and Shift-click runs `livePaint.fill`. With both off, a click runs nothing. With `highlight` off, the face or edge under the pointer is not outlined. + A double-click runs the command a click there runs with `scope: "connected"`, and a triple-click with + `scope: "samePaint"`; Alt held runs nothing, and so does Cmd/Ctrl (`mods.cmd`), whose presses go to the selection + tool Cmd lends. With Space held (`mods.space`, the view tools), a press, a + double-click or a triple-click runs nothing, and a move outlines no face or edge. On a face, `connected` fills the face and every face reached from it + across edges that draw no stroke (an edge that is hidden or fully transparent, or whose strokes are each hidden, + without paint, 0 pt wide or fully transparent); a stroked edge, a hidden face, a gap + that Gap Detection closes and the group's outside stop it. `samePaint` fills every face + of the group, joined to it or not, whose fill is the `match` paint. On an edge, `connected` paints the edge and the + edges joined to it end to end that draw with the stroke paint `match`, and `samePaint` paints every edge of the + group that draws with that stroke paint; the stroke's weight is not compared. An edge draws with the paint of its + topmost stroke that is shown, with paint, above 0 pt and not fully transparent, and with none when the edge is fully + transparent or has no such stroke. Each click of a double-click or triple-click runs as it + arrives and is its own undo step. `match` is the paint the face or edge had before the first of the double-click's + two clicks or the triple-click's three that painted it, or before the double-click when the triple-click's double-click + reached it, so the clicks before the last do not change what it matches; + a first click that made the Live Paint group counts when the face it filled is the clicked face. Hidden faces and edges are not painted. An edge takes the + paint and weight in its topmost stroke that is shown, or in a new stroke above the others when it has none. These rules + for what faces and edges join and what paints match are VectorCraft's own. The help page describes the gestures: + https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html. + `livePaint.fill {scope}` and `livePaint.strokeEdge {scope}` take `scope` as `"one"` (the default), `"connected"` or + `"samePaint"`; any other value is an error and changes nothing. `match` is a paint in the model JSON that + `document.node {id}` writes for an appearance's paints (`{"type": "none"}` for none), taken only with a scope that compares paints, and + defaults to the face's fill or the stroke paint the edge draws with. `livePaint.fill` refuses a `scope` other than `"one"` with + `points`. Both replies add `count`, the number of faces or edges the click reaches, including those that already had the + paint. With the Live Paint Bucket selected and no drag in progress, Left and Right without Shift, Alt or Cmd/Ctrl (which keep their shortcuts) run `paint.setFill {swatch, ids: []}` (or `paint.setStroke` when the Stroke proxy is in front) with the color swatch before or after the active paint in the @@ -3788,7 +3832,8 @@ already walks Image Trace. Short agent entry points: objects; otherwise it returns an error and does not merge the objects. A Live Paint command that changes nothing records no undo step, keeps the redo steps, leaves a saved document saved and replies as usual: `livePaint.fill` (a click, or a Bucket drag) when every face it reaches already has the paint, - `livePaint.strokeEdge` when the edge already has the paint and the width, `livePaint.gapOptions` (and the Gap Options + `livePaint.strokeEdge` when the topmost shown stroke of each edge it reaches already has the paint and the width, + `livePaint.gapOptions` (and the Gap Options dialog's OK) when every group already has the options and `closeGaps` finds no gap, and `livePaint.make` or `livePaint.merge` given one Live Paint group alone, which they select. Such a fill or stroke also leaves the recent colors and the last color as they are. `livePaint.gapOptions` makes a group's faces again, with new ids, only when it adds a path or when Gap Detection is turned on or off or given a new size while it is on. @@ -5673,6 +5718,25 @@ used, as a transform's does. {"name":"run_command","arguments":{"command":"object.transformEach","params":{"scaleH":50,"scaleV":50,"strokes":false}}} ``` +## Scale and Reflect on a turned bounding box + +`object.scale` and `object.reflect` with a named axis act along the selection's bounding box: after a rotation, `sx` +stretches along the box's own width and `sy` along its height, and `"vertical"` and `"horizontal"` are the box's own +axes, until Object › Transform › Reset Bounding Box squares the box to the page. Both take `basis?: "box"|"page"`: +`"box"` is the default, and `"page"` acts along the page's axes. Any other `basis` is an error, and nothing changes. +`origin` is still a page point, and a numeric `axis` is still a line on the page at that angle. Selections whose +objects stand at different angles, and anchors selected with Direct Selection, use the page's axes; with `copy` or +with `objects: false`, the whole objects or their pattern tiles transform, along the box. The journal entry +records the `basis` used, so a recorded action replays in the same frame; a saved step without `basis` (an action or +journal entry recorded before it) plays along the page's axes, as it did when it was recorded. `object.transformAgain` repeats the transform's page matrix, also after Reset Bounding Box. The Scale +and Reflect dialogs, the Transform and Properties panels' flips, the Scale tool and the Reflect tool's default axis +and Shift steps follow the box the same way; a Reflect axis drawn with the tool is the line drawn on the page. + +```json +{"name":"run_command","arguments":{"command":"object.scale","params":{"sx":200,"sy":100}}} +{"name":"run_command","arguments":{"command":"object.reflect","params":{"axis":"vertical","basis":"page"}}} +``` + ## Free Distort on a turned bounding box `object.distort` maps the corners of `from` (TL, TR, BR, BL) onto `corners`, which are page coordinates. With @@ -5882,7 +5946,8 @@ commands send `to: "key"` while the selection has a key, else `"artboard"` for o anchor points, else the Align To choice. The Distribute commands send `to: "key"` while the selection has a key, else the Align To choice, and while there is a key object or a key guide they send the panel's Distribute Spacing value as `spacing`. Edit › Keyboard Shortcuts lists them under Align, with no default shortcut, and -`shortcuts.set {id, shortcut}` gives one a shortcut. +`shortcuts.set {id, shortcut}` gives one a shortcut. The Object › Distribute menu lists the eight Distribute commands, +and each item runs its command. With the Artboard tool chosen, the same buttons and UI commands, and the Control bar's Align and Distribute buttons, run `artboard.align`, `artboard.distribute` and `artboard.distributeSpacing` on the artboards the tool has selected. @@ -6253,8 +6318,9 @@ Color on (HDR or Automatic Color Management) and macOS windows are converted by again. In a Wayland session, and where no profile is set or the profile is not an RGB display profile of at most 1 MiB, the canvas stays sRGB. The app reads the display again every 10 seconds and when the window moves to another display. Exports, `ui.render`, the document, panels and thumbnails are not converted; with Proof Colors on, the proof is drawn -first and then converted. The art is converted before it is drawn over the converted paper, so the blend of a -translucent object or an antialiased edge with the paper can differ slightly from a conversion of the blended color. +first and then converted. Translucent art and the antialiased edges of art over an artboard's paper or the +transparency grid are blended with it in sRGB, and the blend is converted; over the pasteboard and in Outline the art +is converted on its own. `ui.inspect` returns `displayProfile`: `{enabled, display, source, profile, from, applied, reason, osManaged}`, where `source` is `profile`, `srgbProfile` (a profile that shows sRGB unchanged), `system`, `none`, `pending` or `unsupported`, `applied` says whether the canvas is converted, and `reason` says why not. @@ -6562,7 +6628,8 @@ report warn about it). Commands take and return lengths in points (the distances of `object.path.offsetPath`, `object.path.simplify`, `object.path.splitIntoGrid` and `text.setStyle` `size`/`leading` also take a string with a unit, `"5 mm"`). A length string takes the same arithmetic as the desktop app's fields, where `%` is a share of the value before it: -`"200mm*50%"` is 100 mm and `"50pt+25%"` is 62.5 pt. The units +`"200mm*50%"` is 100 mm and `"50pt+25%"` is 62.5 pt. A length string with more than 128 `+ - * /` signs is not read +as a length. The units only change what the UI shows and how it reads typed numbers. Preferences ▸ Units has four: General (rulers, positions and sizes, the Info panel, dialog distances, canvas measurement labels), Stroke (weights, dashes), Type (font size, leading, baseline shift) and East Asian Type (no field uses it yet). General is the active document's units @@ -7283,6 +7350,14 @@ is mixed with middle gray by its alpha. {"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"distort.glass","params":{"distortion":10,"textureImage":{"path":"/tmp/texture.png"}}}}} ``` +Texturizer (`texture.texturizer`) and Rough Pastels (`artistic.roughPastels`) take `params.textureImage` the same +way, as the surface that Scaling, Relief, Light Direction and Invert act on. With `texture: "loaded"` and no stored +texture they draw Canvas. + +```json +{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"artistic.roughPastels","params":{"relief":12,"textureImage":{"path":"/tmp/texture.png"}}}}} +``` + ## Linked images A placed image linked to its file (`file.place {path}`, Link on) records the file's absolute path, its size, @@ -7605,6 +7680,12 @@ bounds; `clip` puts it in a clip group of the old bounds when it is larger). The except for a placed document, which opens in a tab. With the preference `editOriginalSystemDefaults` (Preferences › File Handling › Use System Defaults for Edit Original, on by default) off, Edit Original opens a raster image in the app that made it, PhotoCraft, where it is installed, and anything else in the system's default app (#5236). +The UI command `links.copyInfo {ids?}` (the panel menu's Copy Info) copies the file names of images and placed +documents `ids` to the clipboard, one per line in the order given, and returns `{text, names}`. Without `ids` (or with +`ids: null`, as for the other `ids` commands) it copies the selected ones, and the rows outside the layer tree picked in +the panel, in the order `links.list` gives them; the panel menu passes the selected rows in the order the panel shows +them. An embedded image gives the name its row shows. An id that is not an image or a placed document, a +malformed `ids`, or nothing to copy is an error, and the clipboard is left as it was. ```json {"name":"run_command","arguments":{"command":"links.list","params":{"show":"missing"}}} @@ -8247,6 +8328,16 @@ flattened picture every PSD file carries: Bitmap, Grayscale, Duotone, Indexed, R A document with transparency keeps it. `file.place {path}` links the file like any image (`links.update` reads it again when it changes); Multichannel files are refused. +A PSD or PSB file that names a clipping path (the saved path its clipping path resource names, in the +[published PSD file format specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/)) places, and +opens as its merged image (`layers: false`, a file with no layers, or a file whose layers can't be read), inside a clip group whose clipping +path is that path, under the even-odd rule, so the image shows only inside it. The clipping path is an ordinary path +that can be edited or released. `file.place` answers `image`, the id of the image in the clip group. The +[import options help page](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) +says that a file imported as one flattened image keeps the document's clipping path when it has one. `links.update` +and `links.relink` read the file's pixels again and leave the clip group and its clipping path as they are. Files that name no clipping path, or name a path +they don't have, come in as a plain image. + `document.open` converts a PSD or PSB file's layers to objects. In [PSD import options](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html), the option that converts layers to objects keeps as much of the layer structure as it can, merges the layers of a @@ -9510,6 +9601,24 @@ not kerned). Off by default and saved only when on. One undo step. {"name":"run_command","arguments":{"command":"text.setFormat","params":{"proportionalMetrics":true}}} ``` +## H or V Style and Roman Italics + +With Show East Asian Options on, the OpenType panel menu has H or V Style and Roman Italics +([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)). They are the character +feature tags `hkna` and `ital` of `text.setStyle` and `text.setRangeStyle {features, featureChanges}` +([feature list](https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist)). `hkna` draws kana with the +forms the font makes for horizontal type, and in vertical type the shaper applies `vkna`, the forms for vertical type, +in its place. `ital` draws half-width alphanumerics with the font's italic forms. A font without the feature draws the +characters unchanged. Both are on or off only: `hkna=2` and `ital=0` are refused as unknown features, and `vkna` is +not a tag of its own. A menu item is checked when every character the panel acts on has it; choosing it turns the +feature on for all of them, or off when it is checked, each character keeping its other features, in one undo step. +Reset OpenType Features turns both off. Character styles carry them, and Character Style Options › OpenType Features +has an on, off or unset switch for each. Live SVG text writes them in `font-feature-settings`. + +```json +{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":6,"featureChanges":["hkna","ital"]}}} +``` + ## Variable fonts `text.setFormat {fontAxes: {"wght": 650}}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f5ac18ea2f..3da2ce50b2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -258,8 +258,8 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers row menu commands beyond Collect for Export and menus on guide and blend spine rows (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); DXF export's Include Placed Images and Include Fill Hatches options, off in CNC / CAM (`images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both`; score, gaps and hours unchanged); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 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. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 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); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; 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. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Save refusal continuation (#6146): `READ_ONLY_LOCATION`, Save As on ordinary UI Save only; regression execution pending; score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects other than grouped column, stacked column, line and area graphs the `.ai` reader reads back whole as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 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. Graphs saved to `.ai` in either form open again as live graphs (`ai_editing::tests::graphs_are_written_with_their_records_and_open_live`, `legacy_ai::tests::graphs_are_written_with_their_records_and_open_live`, `cmd::graph::tests::a_drawn_graph_saved_as_ai_opens_as_a_graph_unless_the_save_warns`); other graphs are still written as their art (`graphs_the_records_cant_keep_are_written_as_their_art`); score and hours unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects other than grouped column, stacked column, line and area graphs the `.ai` reader reads back whole as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 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); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; 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. Graphs saved to `.ai` in either form open again as live graphs (`ai_editing::tests::graphs_are_written_with_their_records_and_open_live`, `legacy_ai::tests::graphs_are_written_with_their_records_and_open_live`, `cmd::graph::tests::a_drawn_graph_saved_as_ai_opens_as_a_graph_unless_the_save_warns`); other graphs are still written as their art (`graphs_the_records_cant_keep_are_written_as_their_art`); score and hours unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Save refusal continuation (#6146): `READ_ONLY_LOCATION`, Save As on ordinary UI Save only; regression execution pending; score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658) | 4–7 | estimated (lowered from 85%: target moved); Control bar snapping quick access includes Snap to Glyph and Alignment Guides (#6164); new ruler-guide Alt orientation implemented; global/artboard ruler modes (`view.globalRulers`) and video rulers (View › Rulers › Show Video Rulers, #2849) done; status-bar X/Y uses the active ruler origin and General units; score unchanged | @@ -616,6 +616,7 @@ since 2026-10-01. - **Tab stops in `.ai`/EPS type and right-aligned lines with tabs (#6147):** type from the text document keeps its paragraphs' tab stops (key 30: positions and left, center, right and decimal alignments; leaders aren't read), at most 1,024 a paragraph and 2^20 a story, past which the default stops apply with a warning. In right-aligned area type, the text before a line's last tab sits at its stops, measured from the left edge, and the text after it (none when the tab ends the line, with its hyphen when the line is hyphenated) against the right edge; centred and justified lines, point type and right-to-left paragraphs are aligned as a whole as before. Evidence: `ate::tests::a_paragraph_keeps_its_tab_stops`, `ate::tests::a_story_s_tab_stops_are_bounded`, `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`, `import_fuzz::text_frames_with_hostile_paragraph_attributes_never_panic`. Scores unchanged. - **`.ai`/EPS graphic styles that paint with a named gradient:** a graphic style library's style (or a file's listed style) whose fill, or the fill or stroke of a style of one paint, paints with one of the file's gradients by name loads with that gradient at the style's angle, fitted to the bounds of each object it is applied to (VectorCraft's own placement; the style stores only the gradient's name and angle). A style naming a gradient the file doesn't define, or painting with a pattern, is still left out. Evidence: `fileio::tests_aiimport::graphic_styles_in_a_ai_library_that_paint_with_a_named_gradient_load_with_it`, `style::tests::a_style_of_one_paint_reads_as_the_fill_and_stroke_its_xd_turns_on`. Scores unchanged. - **`.ai`/EPS type laid out closer to the file's page (#6137):** type from the text document keeps auto leading as auto, each line taking the paragraph's Auto Leading share (key 7) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)); the paragraph's hyphenation options (keys 10 to 15) are read; area type of one frame whose last lines the file sets, but which don't fit here, may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines and no more than the file has (`vectorcraft_text::fit_source_lines`), with a warning when it does: the frame can stay as it is, or show only some of them; and a line a soft return ends is justified as a wrapped line is, a paragraph's last line keeping its alignment. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_auto_leading`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores unchanged. +- **Graphs saved to `.ai` keep their data and settings:** a grouped column, stacked column, line or area graph is written to a `.ai` file, in either form, with the records the `.ai` reader reads (`crates/eps/src/import/ai/read/graph.rs`, #7311): `%_Gs`, its plot, type, settings, axes and table, its series' groups each tagged `Go`, and `%_GS` ([the specification](https://13thmonkey.org/documentation/fonts/AI7FileFormat.pdf), §17 and appendix A). The file opens again with the same live graph. A graph with settings the reader doesn't keep, graph designs, label formatting, more than 10,000 categories or 256 series, or art the reader doesn't attach a graph to is written as its art, with a warning. `crates/eps/src/legacy_ai/tests.rs` and `crates/eps/src/ai_editing/tests.rs` write and read back each kind of graph; `a_drawn_graph_saved_as_ai_opens_as_a_graph_unless_the_save_warns` checks every graph type the graph tools draw. Scores unchanged. - **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). The effects listed after it in that fill's or stroke's list keep their own distances and apply to the transformed art and to each copy, as an object's later effects do after the object's Transform effect: a Drop Shadow after it keeps its offset, an Offset Path after it offsets by its own distance, and an SVG filter after it keeps its scale and the origin of its user space. With Transform Objects off, the effects before it keep their distances too. Before, Scale Strokes & Effects scaled a fill's or stroke's later effects by the mean scale, so existing documents with such effects draw differently. Effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)); how the two options treat the effects after a fill's or stroke's own Transform effect is VectorCraft's own rule, following that order (effects `tests::rasterfollow::a_fills_own_transform_leaves_the_shadow_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_offset_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_svg_filter_after_it`, `tests::transformfx::a_fills_own_transform_with_scale_strokes_and_effects_leaves_its_later_effects`; engine `plugins::a_transform_after_a_painted_fill_effect_leaves_the_later_shadow_on_every_paint`). Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. @@ -743,6 +744,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. - **Document Setup › Color:** a Color tab shows the document color mode (RGB Color or CMYK Color), a Convert to CMYK or Convert to RGB box for a mode change (cleared: the colors keep their numbers), and the RGB and CMYK profiles assigned to the document, with a working-space entry for none. OK runs `edit.assignProfile` and then `file.documentColorMode` with the dialog's other changes in one `command.batch`, and the document's changes are one undo step. An unchanged Color tab adds no color commands, and Cancel, or OK with nothing changed, records no undo step. A Color tab field with a value of the wrong kind is an error, and the dialog stays open. The tab is VectorCraft's own layout; File › Document Color Mode and Edit › Assign Profile… stay as they are. Tests in `dialogs/document_setup.rs`. - **Canvas through the display's profile on Windows and X11:** Preferences › Performance › Use Monitor Profile for Canvas (on by default) converts the canvas's art, artboard paper and transparency grid from sRGB into the ICC profile of the display that shows most of the main window, after any proof, as the help page describes the monitor's profile translating the document's colors for the monitor ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=151)). Windows gives the display's default profile, and a display with Advanced Color on is left to Windows ([Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/wcs/advanced-color-icc-profiles)); X11 gives colord's profile for the output or the `_ICC_PROFILE` property. macOS converts the window itself, and a Wayland session stays sRGB. `ui.inspect` reports `displayProfile`. Tests in `color/src/cms/display.rs`, `ui-egui/src/display_profile.rs` and `canvas::tests::the_canvas_is_drawn_through_the_display_profile_while_the_preference_is_on`. +- **Translucent art blended before the display profile:** with Use Monitor Profile for Canvas converting, translucent art and the anti-aliased edges of art over an artboard's paper, a colored artboard or the transparency grid are blended with it in sRGB and the blend is converted, so the monitor's profile translates the document's colors as they are composited ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=151)). The frame rendered whole, the part an edit renders again, the render worker's frame, the pan fill and the other windows' panes do the same; over the pasteboard and in Outline the art is converted on its own, and with no profile to convert into nothing changes. Tests `display_profile::tests::translucent_pixels_over_the_paper_are_blended_before_they_are_converted`, `the_transparency_grid_is_blended_cell_by_cell`, `the_backdrop_is_found_through_the_view` and `canvas::tests::translucent_art_over_the_paper_is_blended_before_the_display_profile`. - **Out-of-gamut warning after a profile change:** the out-of-gamut warning and its closest-color correction in the Color panel and Color Picker ([the help page](https://helpx.adobe.com/illustrator/using/adjusting-colors.html)) check the color against the document's assigned CMYK profile, else the working CMYK space, and update when Edit › Assign Profile… or Edit › Color Settings… changes that space or a loaded profile changes, without a change of color. Which CMYK space the warning uses is VectorCraft's own rule. Tests: `gamut_warning_follows_a_profile_change` and `gamut_warning_follows_a_profile_loaded_again` in `dialogs/color_picker/tests.rs`. - **Assign Profile and the working spaces:** Edit › Assign Profile… tags the document and leaves the working spaces of Edit › Color Settings… as they are, in Color Settings, the Separations panel and the settings saved for the next start. Colors show and convert through the profiles assigned; a change in Color Settings keeps the profiles the document in front is assigned in use, and removing an assigned profile in use puts the working space back in its place. The profiles in use follow the document in front: a new, opened or reverted document, a switch to another tab, a close, Undo and Redo of Assign Profile, a failed batch, each step of a batch, Export All Open and the update of placed documents put that document's profiles in use, and the working spaces where it is assigned none or no document is open. With Window › Arrange › Tile or floating document windows, each document behind the front one draws its art, paper and transparency grid through its own assigned profiles, and loading or removing a profile redraws the documents assigned it; placed documents inside it still draw through the front document's profiles. How documents behind the front one draw is VectorCraft's own rule. View › Proof Setup › Working CMYK simulates the working CMYK space of Color Settings in Proof Colors and Separations Preview, also in a document assigned another CMYK profile. Public references: [Changing the color profile for a document and About color working spaces](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=150) and [Soft-proof presets](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=134). Tests in `engine/tests/color_settings.rs`. - **New Document's More Settings and Save Preset:** More Settings opens a pop-up over New Document with only Number of Artboards, the artboard layout, Change to Right-to-Left Layout, Spacing and Columns; Done, Escape or a click outside closes it and keeps the values. Save Preset turns the top of Preset Details into a Save Document Preset heading with the preset's name, Cancel and Save, and hides the document's name, Close and Create until the preset is saved or canceled; nothing in that mode creates a document. Save Preset's default name is the first "Preset N" no saved preset has. VectorCraft's own layout. Harness tests in `dialogs/new_document.rs` cover both. @@ -910,6 +912,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Hindi (2026-11-06): Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged. [Terminology and limitations](localization-hi.md). - Gujarati (2026-11-14, #6613): `gu` has 4,673 catalog rows, zero/one singular plurals, installed desktop font fallback and support for optional `Gujr` craft-font faces. The original 4,286 rows remain unchanged; all 387 additions are contributor-reviewed and approved, with 11 wording corrections. Status/error messages remain partial; the pinned fonts and web startup list have no Gujarati face, so web selection remains unavailable until one is bundled. Current-head validation is recorded in the PR; scores and estimates unchanged. - Hindi wording (2026-11-13): rows outside the plug-in messages keep their English's meaning, quantities and clauses, and use the catalog's save, swatch, crop, resolution, path and type terms; a test checks those terms and the point unit in every row; native review remains; scores and estimates unchanged. + - Hindi word senses (2026-11-13): 85 labels and messages use the interface sense of their English: Desired is वांछित and the range message keeps both comparisons, rulers are रूलर, text characters कैरेक्टर, vector points पॉइंट, fills फ़िल्स and High Contrast उच्च कंट्रास्ट; the glossary test rejects the unrelated senses in every row; native review remains; scores and estimates unchanged. - Hindi terms (2026-11-13): every row that names a variable (data merge, variable fonts and widths, environment variables) says वेरिएबल, and joining paths, anchor points, point type and lines says जोड़ें instead of शामिल हों, which stays for joining the community; a test checks both terms in every row; scores and estimates unchanged. - Hindi English words (2026-11-13): 264 rows no longer keep English words in their sentences, repeat a parameter's name in Hindi or garble them, among them "unknown align", which now names the alignment; lists of accepted values such as `(left|right|top|bottom)` stay as their English writes them; two tests check every row; native review remains; scores and estimates unchanged. - Message field names and values (2026-11-13): 49 status and error messages in the Hindi, Bulgarian, Danish, Indonesian, Dutch, Brazilian Portuguese, Swedish and Turkish catalogs keep the field names in brackets, such as `[subpath, anchor]`, and the values an `unknown` message lists, such as `(points, lines)`, as their English writes them; a test checks every catalog; scores and estimates unchanged. @@ -950,7 +953,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Align New Objects to Pixel Grid (`document.setup {alignNewObjectsToPixelGrid}`, a checkbox in New Document's Advanced Options and a checked item in the Transform panel menu) gives every path and compound path drawn in the document, the paths of a new grid or flare included, the pixel-aligned property, snapped in the step that draws it; it is on in the Mobile and Web presets and off in the others, saved presets and Recent keep it, and the document saves it. Type, images and groups do not take it, and art already in the document, pasted, duplicated or placed keeps its own property (VectorCraft's own rules) ([Pixel-aligned property](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html); `tests::pixelalign::align_new_objects_makes_new_paths_pixel_aligned`, `align_new_objects_reaches_grids_flares_and_add_on_shapes`, `align_new_objects_is_saved_and_read_back`, `tests::newdoc::align_new_objects_to_pixel_grid_follows_the_preset`, `panels::transform::tests::align_new_objects_menu_item_toggles_the_active_document`, `dialogs::new_document::tests::align_new_objects_checkbox_follows_the_preset_and_reaches_the_document`). - Break Link to Symbol and Object › Expand give a symbol instance's art the instance's own bounding box: after Reset Bounding Box the art stands square to the page, an object turned inside the symbol keeps its angle relative to the instance's box, a group added for the instance's opacity takes the instance's box, and the clip groups of 9-slice art take the angle of their cell. Compound shapes and Pathfinder receive an instance's art with the same angles (VectorCraft's own behavior; `bboxrotate::reset_bounding_box_then_expand_symbol_instance`, `reset_bounding_box_then_expand_keeps_the_angles_inside_the_symbol`, `expanding_a_faded_symbol_instance_gives_its_group_the_instance_box`, `expanding_a_reflected_symbol_instance_keeps_its_box`, `symbol_nine_slice::break_link_turns_the_cells_of_a_sliced_instance_with_its_box`, effects `outline_object_turns_an_instances_art_with_its_box`). - Object › Transform › Reset Bounding Box squares the box of every object inside the selected ones as well: the children of groups, compound paths, clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks. Ungroup and the release commands then bring back boxes square to the page (VectorCraft's own behavior; `bboxrotate::reset_bounding_box_squares_the_objects_inside_a_group`, `reset_bounding_box_squares_the_turned_members_of_a_square_group`, `reset_bounding_box_then_release_clipping_mask` and the other `reset_bounding_box_then_*` tests, `reset_bounding_box_leaves_the_art_of_an_unlinked_opacity_mask`). - - After a rotation the Free Transform tool shows and drags the same turned bounding box as the Selection tool: its handles, their cursors and its rotate zone sit on the selection's own sides, a handle scales along those sides from the opposite handle ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html)), Cmd/Ctrl on a side handle shears along that side, and Perspective Distort and Free Distort start from the turned box's corners (`object.distort` takes `angle` with `from`). The Scale and Reflect tools, their dialogs and commands, and the Transform panel's flips keep the page's axes (VectorCraft's own rule; `bboxrotate::free_transform_handles_turn_with_the_object_and_scale_along_its_axes`, `free_transform_shears_along_the_turned_side`, `free_transform_distort_starts_from_the_turned_box`, `xform::distort_reads_from_in_a_turned_frame_and_rejects_a_bad_angle`, tools `xform::free::tests::the_box_and_its_handles_turn_with_the_selection`). + - After a rotation the Free Transform tool shows and drags the same turned bounding box as the Selection tool: its handles, their cursors and its rotate zone sit on the selection's own sides, a handle scales along those sides from the opposite handle ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html)), Cmd/Ctrl on a side handle shears along that side, and Perspective Distort and Free Distort start from the turned box's corners (`object.distort` takes `angle` with `from`) (`bboxrotate::free_transform_handles_turn_with_the_object_and_scale_along_its_axes`, `free_transform_shears_along_the_turned_side`, `free_transform_distort_starts_from_the_turned_box`, `xform::distort_reads_from_in_a_turned_frame_and_rejects_a_bad_angle`, tools `xform::free::tests::the_box_and_its_handles_turn_with_the_selection`). + - After a rotation the Scale and Reflect tools, the Scale and Reflect dialogs, `object.scale`, `object.reflect` with a named axis and the Transform and Properties panels' flips act along the selection's turned bounding box until Reset Bounding Box: the horizontal and vertical scale stretch along its own width and height, and the vertical and horizontal reflection axes are its own. The Reflect tool's default axis and its Shift steps start from the box's axes, and an axis drawn with the tool is the line drawn on the page. `basis: "page"` keeps the page's axes (VectorCraft's own rules; `bboxrotate::scale_and_reflect_follow_the_turned_box`, tools `xform::transform::tests::scale_and_reflect_drags_follow_a_turned_bounding_box`). - Perspective Distort and Free Distort (`object.distort`) turn each warped object's bounding box as the warp turns the object at its own center, the objects inside groups, compound paths and clip groups and the art of linked opacity masks included, so the next Selection or Free Transform drag uses the tilted box; Object › Transform › Reset Bounding Box squares it again, and corners left where they are add no undo step (VectorCraft's own rule; `bboxrotate::distort_turns_an_upright_box_with_the_warp_at_its_center`, `distort_turns_each_member_and_linked_mask_by_its_own_warp`, `mirrored_and_collapsing_distorts_keep_valid_angles`, `distort_angles_undo_redo_and_reopen_together`, `xform::unchanged_distort_records_no_history`). - The Free Transform widget's Constrain works in Free Distort as well as in Free Transform ([the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/free-transform-tool.html)). With Constrain on, or Shift held, a corner dragged in Free Distort moves only along the line through the opposite corner, in the box's own proportions, and the other three corners stay in place; on a turned box the line runs through the box's own corners. The line is VectorCraft's own rule. Cmd/Ctrl+Alt+Shift on a corner still drags in Perspective Distort, where Constrain stays unavailable (tools `xform::free::tests::free_distort_corner_keeps_to_its_diagonal_with_shift_or_constrain`, `shift_pressed_during_a_free_distort_drag_constrains_it`, `a_turned_box_is_constrained_along_its_own_diagonal`, `along_diagonal_of_a_degenerate_box`; engine `xform::free_transform_constrained_distort_keeps_the_corner_on_its_diagonal`; ui-egui `free_transform::tests::the_widget_sets_the_mode_and_a_corner_drag_then_distorts`). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). @@ -986,7 +990,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Delete Symbol asks first** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Alt-click Delete](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): the Symbols panel's Delete Symbol button and menu item ask "Delete the selected symbol?" with Yes and No for a symbol no instance uses. For a symbol with placed instances they ask with Expand Instances (the instances become ordinary art), Delete Instances and Cancel; either Instances button deletes the symbol in one undo step. Alt-click on the button deletes an unused symbol without asking and still asks about one with instances. A question left open while another document became active deletes nothing. `symbol.delete` itself does not ask. Tests: `panels::symbols::tests::delete_symbol_asks_first_and_alt_click_deletes_an_unused_symbol_at_once`, `the_in_use_question_expands_or_deletes_the_instances`, `a_stale_delete_symbol_question_deletes_nothing`, `the_delete_symbol_questions_show_their_buttons`. - **Symbols panel context menu** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): right-clicking a symbol in thumbnail view or list view displays a context menu with Place Symbol Instance (passes view center or artboard center), Edit Symbol, Duplicate Symbol, Delete Symbol, Break Link to Symbol (enabled when canvas selection includes instances of this symbol), and Select All Instances. Right-clicking an unselected symbol selects it first (`symbol.setCurrent`). Tests: `panels::symbols::tests::right_click_context_menu_in_symbols_panel_runs_actions`. - **Symbols › Select All Unused** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), #4918): the Symbols panel menu's Select All Unused shows the symbols nothing places selected (`symbol.unused`): instances in the art (opacity masks included), graph designs, pattern tiles and brush art count, and so do instances in a used symbol's art. Delete Symbol, the button or the menu item, then asks "Delete the selected symbols?" once and deletes those still unused in one undo step (`symbol.delete {names}`); Alt-click on the button deletes them without asking, and a click on a symbol picks just it again. Tests: `tests::brushsym::symbol_unused_lists_the_symbols_nothing_places_and_delete_takes_several`, `panels::symbols::tests::select_all_unused_picks_the_unused_symbols_and_delete_deletes_them`, `dialogs::symbol_delete::tests::several_symbols_get_the_plural_question`. - - **Symbols › Sort by Name and the list views** ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Sort by Name puts the symbols in alphabetical order (`symbol.sortByName`), and its Thumbnail View, Small List View and Large List View show them as thumbnails, in a compact list or in a list with larger thumbnails. VectorCraft's own rules: the sort ignores case and keeps names that differ only in case in their order; it changes the document's order, which `symbol.list` reports, as one undo step, keeps the current symbol and adds no undo step when the symbols are already in order; Thumbnail View is the default, Small List View has 18-point rows with 14-point thumbnails and Large List View 26-point rows with 22-point thumbnails, and a click or double-click on a symbol does the same in every view. Tests: `tests::brushsym::symbol_sort_by_name_orders_the_symbols_in_one_undo_step`, `panels::symbols::tests::sort_by_name_and_the_small_and_large_list_views`. + - **Symbols › Sort by Name and the list views** ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Sort by Name puts the symbols in alphabetical order (`symbol.sortByName`), and its Thumbnail View, Small List View and Large List View show them as thumbnails, in a compact list or in a list with larger thumbnails. VectorCraft's own rules: the sort ignores case and keeps names that differ only in case in their order; it changes the document's order, which `symbol.list` reports, as one undo step, keeps the current symbol and adds no undo step when the symbols are already in order; Thumbnail View is the default, Small List View has 18-point rows with 14-point thumbnails and Large List View 26-point rows with 22-point thumbnails, and a click or double-click on a symbol does the same in every view. `ui.panelView` sets the view of the Symbols panel, and of the Swatches and Graphic Styles panels, as their panel menus do. Tests: `tests::brushsym::symbol_sort_by_name_orders_the_symbols_in_one_undo_step`, `panels::symbols::tests::sort_by_name_and_the_small_and_large_list_views`, `panels::symbols::tests::panel_view_sets_the_view_of_each_panel`. - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). @@ -1137,6 +1141,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Smart Guides on, a straight Knife cut snaps to anchor points and paths: [Cut objects using the Knife and Scissors tools](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) describes the Knife snapping to anchor points and paths while Smart Guides are on. Its ends snap as the ends of the Pencil's straight line do, to anchor points and path segments of art that is shown: the press, and the release of an Alt-drag. Shift+Alt steps 45° from the snapped press, and its release stays on that step. With Smart Guides off, or with Snap to Pixel or Snap to Grid on, the cut is not snapped. The dashed line and `path.knife` use the snapped points. Eraser Tool Options has Angle and Roundness besides Size, as [Erase paths using the Eraser tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/erase-paths-using-eraser-tool.html) describes: the Eraser's tip is an ellipse Size across, its long axis at Angle and its short axis Roundness percent of the long one, and a drag with a tip that is not round shows the outline of the area it erases (`path.eraseRegion` `angle` and `roundness`). The outline a Blob Brush or Eraser drag shows is built a bounded amount per frame, so a long stroke does not slow the canvas: each frame takes at most 256 new points of the drag and outlines again only the pieces of up to 32 segments they change, and the parts of a piece's outline inside the rest of the area are removed a limited amount at a time. While work is left, the canvas draws again with the pointer still, the points not outlined yet show as a thin line, and the tip shows at the pointer. With Size set to Pressure the outline also shows only the edge of the whole area. The release sends the whole stroke as before (`path.blob`, `path.eraseRegion`). + The settled part of that outline is kept in paths of up to 64 pieces each. A path is shared from one frame to the next until a piece in it changes, and the canvas keeps the triangles it drew the path with while the view stays the same, so a frame does not copy, flatten or tessellate a settled path again; a pan, zoom or rotation draws each one once more. This is VectorCraft's own rule. The outline of a drag with a round Eraser tip is drawn whole each frame. - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag). A Reshape drag stretches part of a path from the dragged point without losing the path's general form, as [Adjust path segments](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/adjust-path-segments.html) has it. On an open path the stretch reaches both ends: the end anchors stay in place, each anchor between moves less the nearer it lies to an end, and the handles of a smooth anchor stay in line (`path.reshape`; the reach and the shares are VectorCraft's own rule, in `docs/mcp.md`). A click sets a focal point and a click on a segment adds a highlighted anchor there; Shift-click adds more, and a drag from any of them moves them all. The anchors that are not direct-selected stay in place and a closed path selected whole moves whole (VectorCraft's own rule, in `docs/mcp.md`). Align, Distribute, Distribute Spacing, Reflect and Shear return an error for any `horizontal`, `vertical`, `to`, `axis`, `bounds` or `spacing` value they don't take, listing the choices, and Align returns one when a call gives neither `horizontal` nor `vertical`; nothing moves and no undo step is recorded. Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing). @@ -1381,6 +1386,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Glyphs panel's Alternates for Current Selection (#5514) applies the OpenType feature that draws the glyph to the selected character alone, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Alternates For Current Selection in the Show menu; double-click the glyph to replace the selected character). One deliberate difference from a literal reading: the text keeps the Unicode character and gains a character-style feature with a value (`salt=2`, `ss03`; `text.setRangeStyle {featureChanges}`) instead of taking an unencoded glyph in its place. The grid lists the alternates of the glyph the shaper makes for the character in its word (a medial letter shows its medial variants; the default glyph comes first and the current glyph is highlighted); a variant no supported feature draws there stays enabled as a replacement glyph (`text.setGlyphAlternate`, including positional alternates), and its tooltip says it may not join its neighbors; the default glyph takes the character's alternate features off again. Runs that differ only in their OpenType features are shaped as one piece of text, each run's features over its own characters, so the letters of an Arabic word keep their joining forms. Show › Entire Font also lists the glyphs no character maps, with the font's name for them (or `gid N`) and the feature that gives them. It applies the selected letter's contextual alternates the same way, and applies a glyph no feature reaches whose name does not belong to another letter as a glyph choice; other glyphs are dimmed with the reason. An Arabic presentation form picked there inserts its base letter. These rules are VectorCraft's own. Tests: `tests::glyph_alternates` in the text crate (a fixture font; Kufi Murabba PIIIX when it is installed), `panels::glyphs::tests` (including positional glyph choices, unlisted glyph name ownership, apply and Undo), `tests::textedit::feature_values_are_set_replaced_and_saved`. With a Type tool and exactly one character selected, the canvas shows that character's alternate glyphs in a row beside it, and a click on one replaces the character's glyph, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes ("Replace a character with on-canvas alternate glyphs"). The row shows at most five alternates, and an arrow at its end opens the Glyphs panel on Alternates for Current Selection when the character has more, as the [reference manual](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=479) describes. A click runs the command the panel's double-click runs, as one undo step, so a letter of an Arabic word keeps the word joined. These rules are VectorCraft's own: the row leaves out the glyph the character is drawn with and puts the default glyph first for a character drawn with an alternate; it stands under a character of horizontal type and on the right of a character of vertical type, or on the other side when the canvas ends there or the Contextual Task Bar is there; Preferences › Type › Show Character Alternates (`showCharacterAlternates`, on by default) turns it off. Tests: `canvas::alternates_bar::tests`, `tests::prefs::show_character_alternates_preference_is_on_by_default_and_saved`. The OpenType panel's Stylistic Sets menu offers `ss01`–`ss20` sets present in the selected text's font, using the font's custom labels when available and numbered labels otherwise. Sets can be combined and deselected individually. With mixed fonts, the menu shows the first selected character's font's sets and applies the feature tag to all selected text; a font without that set draws unchanged. The mixed-font rule is a VectorCraft design choice. Behavior reference: [Add stylistic sets to selected text](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html) and the [OpenType specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt). + With Show East Asian Options on, the OpenType panel menu has H or V Style and Roman Italics, as [Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html) describes. H or V Style sets the feature tag `hkna`, which draws kana with the forms the font makes for horizontal type, and with `vkna` for vertical type in vertical type; Roman Italics sets `ital`, the font's italic forms of half-width alphanumerics ([OpenType feature list](https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist)). Each is checked when every selected character has it and turns it on or off for all of them in one undo step; character styles carry both in OpenType Features. Tests: `panels::opentype::tests::kana_style_and_roman_italics_turn_on_and_off`, `features::tests::kana_style_and_roman_italics_are_kept_and_shaped`. `text.setStyle {stroke, strokeWidth}` sets the character stroke's color and weight of the selected type or of a range of its characters (the weight in document points, as `stroke.set` sets it), in the same undo step as the command's other changes. Tests: `tests::textedit::set_style_sets_the_character_stroke`. - **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged. For Auto Size area type in columns, the bisection for the frame's height runs on the lines of the one-column layout, and its result is checked with at most two layouts of the columns. When the text wraps around objects or does not fit the one-column layout, or when the check fails, the columns are laid out at every step of the bisection, as before. Typing at the start of a story of 50,000 characters in 2 columns took 1,670 ms a keystroke and now takes 292 ms (release build, `auto_height_in_columns_typing_timing`). @@ -1525,7 +1531,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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`. Double-clicking the Live Paint Bucket opens Live Paint Bucket Options ([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)): Paint Fills (on) and Paint Strokes (off) set whether a click paints faces, edges or both, Highlight turns the outline of the face or edge under the pointer on or off, and Color and Width set it (red and 4 px by default). Faces are outlined at the Width and edges at half of it, as the page says faces are highlighted with a thick line and edges with a thin line. With both on, a click paints the edge within 4 px of the pointer, else the face, and Shift switches to Paint Fills only; with one on, Shift switches to the other. The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintBucket"}`). Evidence: `builder::tests::{bucket_options_choose_what_a_click_paints, bucket_options_set_the_highlight, bucket_highlights_an_edge_thinner_than_a_face}`, `dialogs::live_paint_bucket::tests::double_clicking_the_tool_opens_its_options_and_ok_sets_them`. + Live Paint Bucket double-click and triple-click, as [the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) describes them: a double-click on a face fills it and the faces reached across edges that draw no stroke, and on an edge it paints the edges joined to it with the same stroke; a triple-click fills every face of the group with the face's fill, or paints every edge with the edge's stroke (`livePaint.fill` and `livePaint.strokeEdge` `{scope: "connected" | "samePaint", match?}`; `pointer_gesture` and `ui.pointer` take `tripleclick`). Each click paints as it arrives, as its own undo step, and the paint compared is the one the face or edge had before the first of its clicks that painted it; a first click that made the Live Paint group counts when the face it filled is the clicked face. With Space held for the view tools, a click, double-click or triple-click paints nothing and no face or edge is outlined, and with Cmd/Ctrl held a double-click or triple-click paints nothing. Which faces and edges join and which paints match are VectorCraft's own rules. Evidence: `tests_build::{live_paint_bucket_double_click_floods_across_unstroked_edges, live_paint_bucket_double_click_floods_curved_and_narrow_faces, live_paint_bucket_double_click_floods_across_hidden_edges_and_past_gaps, live_paint_bucket_triple_click_fills_every_face_of_the_same_fill, live_paint_bucket_triple_click_after_making_the_group_matches_the_made_fill, live_paint_bucket_double_and_triple_click_paint_joined_and_same_stroke_edges, live_paint_scope_and_match_are_checked}`, `builder::tests::{bucket_double_and_triple_click_widen_what_a_click_paints, bucket_triple_click_after_making_the_group_on_another_face_matches_its_own_face, bucket_triple_click_with_a_click_on_another_face_between_matches_the_first_fill, bucket_highlights_nothing_with_space_held}`, `tests::build::{live_paint_scoped_fill_and_stroke_skip_parts_with_the_paint, live_paint_bucket_cmd_double_and_triple_click_paint_nothing}`, `tests::livepaint::{a_triple_click_fills_every_face_of_the_same_fill, clicks_with_space_held_paint_nothing}` (MCP). Live Paint Bucket Options has Cursor Swatch Preview, as the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) lists: with the Bucket selected, the active fill or stroke shows above the pointer between the color swatches before and after it in the Swatches panel, and the Left and Right arrow keys pick the swatch before or after it, as the page describes. The selected art keeps its paint. As VectorCraft's own rules, the option is on at first, the arrows step the stroke when the Stroke proxy is in front, they wrap around at the ends and still step with the preview off, a color that is no swatch shows alone until Right picks the first swatch or Left the last, and the arrows do nothing during a fill drag or with no color swatch. Evidence: `tests::build::{live_paint_bucket_arrow_keys_step_the_active_paint_through_the_swatches, live_paint_bucket_arrow_keys_handle_unmatched_paint_few_swatches_and_drags, live_paint_bucket_arrow_keys_pass_swatches_of_the_same_color_for_fill_and_stroke, swatch_arrow_keys_leave_modified_arrows_to_the_shortcuts}`. + Object › Live Paint › Make turns the selected paths into a Live Paint group of faces and edges ([the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)). As VectorCraft's own rule, each face takes every fill row that draws (shown, with paint and an opacity above 0) of its front-most covering source that has one and an opacity above 0, and each edge every stroke row that draws (also with a weight above 0) of its source, in order with their opacity, blending mode, effects and stroke settings, with gradients kept at their fit on the source and with the source's opacity and blending mode. Where a path is cut into several edges, each edge's dashes go on from where it starts along the path with the lengths set (dashes aligned to corners and path ends are not fitted to the cut edges), the arrowheads stay at the path's own ends, and a width profile or brush stays only on an edge that runs the whole path; each edge runs the way its path does, a gradient applied along a stroke starts again on each cut edge, and an inside or outside stroke is drawn centered on an open piece of a closed path. A painted lower row under an unpainted or hidden top row is kept, a hidden top row does not become visible, and a source in front whose fill does not draw does not hide the fill behind it. Rows that do not draw stay with the stored sources, which Release returns unchanged; the source's object effects are not carried. Evidence: `tests::build::{live_paint_make_keeps_lower_drawn_stroke_rows, live_paint_make_fits_dashes_and_arrowheads_to_each_edge, live_paint_make_starts_a_closed_edge_where_its_path_starts, live_paint_make_keeps_the_direction_of_gradient_strokes, live_paint_make_keeps_all_drawn_fill_rows_and_attributes, live_paint_make_uses_frontmost_source_with_drawn_fill}`. Live Paint Make preserves the front-most covering source's fill when compound paths share a curved boundary, and Expand keeps the face fills. Regression evidence: `planar::tests::coincident_curves_keep_their_source_faces` checks source order, winding, fill rules and nearby boundaries; `tests::build::live_paint_make_and_expand_keep_abutting_compound_colors` checks native compound paths, Make, Expand and Undo/Redo. This preserves VectorCraft's source-fill rule without changing arrangement precision. Object › Live Paint › Expand keeps the faces and edges that draw on the canvas, with all their fill and stroke rows, so the expanded paths look as the painted group did ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)). As VectorCraft's own rule, a face or edge draws when it is shown, its opacity is above 0, and any of its fill or stroke rows is shown, has paint and an opacity above 0 (a stroke also needs a weight above 0), whichever row is on top: a painted lower row under an unpainted top row is kept, and a part whose only painted row is hidden is removed. Merge copies the fill and stroke rows, visibility, opacity and blending mode of each face that draws by this rule, and of each edge, to the rebuilt parts, with their gradients kept at their fit on the old outline and without the strokes' pen pressure, tilt, bearing and rotation. Evidence: `tests::build::{live_paint_expand_keeps_the_faces_and_edges_that_draw, live_paint_merge_carries_the_faces_that_draw, live_paint_merge_leaves_pen_pressure_behind, live_paint_merge_keeps_the_fit_of_carried_gradients, live_paint_merge_keeps_the_visibility_opacity_and_blending_of_carried_parts}`. `livePaint.info` lists every fill and stroke row of each face and edge, bottom first, with its paint, opacity, visibility, weight for a stroke, and whether it draws by the rule above, beside the top fill, stroke and weight it already reported. Evidence: `tests::build::live_paint_info_lists_every_appearance_row`. @@ -1763,7 +1771,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` opened from its editing data opens its Objects on Path groups live, with their path, their objects in order along it, Pivot and Rotate, and their art where the file draws it; a group whose spacing has been dragged, or with options an Objects on Path group doesn't hold, opens as a plain group (`crates/eps/src/import/ai/tests.rs`). `.ai` symbol definitions and placed instances open as live symbols, in the Symbols panel's order; an instance of a symbol that isn't read, or whose appearance differs from its symbol's, comes in as the page draws it (`crates/eps/src/import/ai/tests.rs`). In a `.ai` opened from its editing data, an object in a pattern's art or a brush's art with an art style VectorCraft reads opens as itself with its effects live, as an object on a layer does, and an object in a pattern's art keeps the id of a graphic style in the file's list (`crates/eps/src/import/ai/tests.rs`). - A graph in a `.ai` whose records have the shape and values the published version 7 specification gives ([the specification](https://13thmonkey.org/documentation/fonts/AI7FileFormat.pdf), printed pp. 109–112 and 146–158) opens as a live grouped column, stacked column, line or area graph with its saved art, table, axes and settings, and Graph Data edits it ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-data.html)); other graphs keep their art as plain objects with a note, and saving a graph to `.ai` warns that its data and settings aren't written (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/cmd/graph/tests.rs`). + A graph in a `.ai` whose records have the shape and values the published version 7 specification gives ([the specification](https://13thmonkey.org/documentation/fonts/AI7FileFormat.pdf), printed pp. 109–112 and 146–158) opens as a live grouped column, stacked column, line or area graph with its saved art, table, axes and settings, and Graph Data edits it ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-data.html)); other graphs keep their art as plain objects with a note. Saving to `.ai` writes these records for a graph that reads back as the same graph, and warns that any other graph's data and settings aren't written (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/cmd/graph/tests.rs`). Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`). A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art (Art brushes in the older and newer forms of their objects). A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in None or Tints keeps that colorization, and one in the newer form also keeps its fixed width, Flip Along and Flip Across, and Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with its guides ([Art brush options](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf), printed page 193). Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush in the newer form that bends art of solid-filled paths with those options, open as the path with the brush on its stroke, except paths whose brush scales its art proportionately. Paths with an Art brush in the older form, and other brushed paths, still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). @@ -1813,6 +1821,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Targa files (`.tga`, `.vda`, `.icb`, `.vst`) open, place linked or embedded, relink and update at their pixel size: color-mapped, true-color and black-and-white, raw or run-length encoded, in any of the four row orders. The image descriptor's attribute bits and the extension area's Attributes Type decide the transparency: a file with no attribute bits, or with attribute data the Attributes Type calls undefined, opens opaque; with one bit, a 16-bit true-color pixel takes that bit as its alpha, and a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; premultiplied colors are divided by their alpha. Open and Place recognize a Targa file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a Targa file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/tests/links.rs`). - BMP files named `.dib` or `.rle` (run-length encoded bitmaps included), Photoshop files named `.pdd` and PNG files named `.pns` open and place, linked or embedded, as BMP, PSD and PNG files, in any letter case: the Open and Place dialogs list them with their format, the Links panel names their format, and Edit Original opens them. Exports write only `.bmp`, `.psd` and `.png` (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/cmd/fileio/tests_psdread.rs`, `crates/engine/src/tests/links.rs`). - File › Open converts a PSD or PSB file's layers to objects, keeping the layer structure, merging clipped layers into one layer and leaving out hidden layers that would need a merge, as [PSD import options](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) describes for the option that converts layers to objects; hidden layers come in with `hiddenLayers`, as Import Hidden Layers brings them in, and `layers: false` opens the merged image. VectorCraft's own rules: each layer becomes a layer holding its pixels with its name, visibility, opacity and blend mode, groups become layers of sublayers, layers in Dissolve, Linear Burn, Linear Dodge, Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide, Darker Color and Lighter Color are merged with the layers below, layer masks become opacity masks, fill opacity multiplies the opacity (in a layer that others are clipped to, it fades only that layer's own pixels), and adjustment layers, layer effects, knockouts and vector masks aren't applied and are counted in the warnings. 8-, 16- and 32-bit files, PSB, raw, PackBits and zipped channels. There is no PSD import options dialog yet (`crates/engine/src/cmd/fileio/tests_psdlayers.rs`). + - A PSD or PSB file's clipping path, the saved path that image resource 2999 names in the [PSD file format specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), clips its merged image on File › Place and on File › Open of the merged image, as [PSD import options](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) describes for a file imported as one flattened image. The image comes in a clip group whose clipping path has no paint and uses the even-odd rule, and the path can be edited or released. `links.update` and `links.relink` keep the clip group, and files that name no clipping path come in as before (`crates/engine/src/cmd/fileio/tests_psdread.rs`). - PCX files (`.pcx`) open and place, from the ZSoft PCX Technical Reference Manual: 1 bit in the header palette's first two colors (black and white when the version has no palette or both colors are the same), 1 bit in two to four planes and 4 bits with up to 16 colors from the header's palette (the EGA colors for versions 0 and 3), 2 bits in the CGA colors that the header's CGA palette settings pick (background, color burst, palette and intensity), 8 bits with the 256-color palette after the image data (gray levels without one) and 24-bit color in three planes, run-length encoded. The header's HDpi and VDpi size the image. A file of another depth or plane count, one cut short, and one whose pixels as RGBA pass the pixel allowance are refused with a message before its pixels are decoded. Open and Place recognize a PCX file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a PCX file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_pcxread.rs`). - Pixar picture files (`.pxr`) open, place and paste, from "The Format of Stored Pictures" in the Pixar Image Computer manuals: any set of the R, G, B and A channels in 8 or 12 bits, dumped or run-length encoded with all four packet kinds and block ends, in tiles of any size (pixels of border tiles past the picture are dropped). R alone is gray, a missing color channel is 0 and a missing A opaque; 12-bit samples are clamped to 0.0 to 1.0; color matted to black becomes straight color; a null tile is transparent when the picture has A and black otherwise. The offsets aren't read, and the image takes the default resolution. Version 2 headers read like version 0. A file cut short, one with a tile whose writing wasn't finished, one with header values or packets the format doesn't define, and one whose pixels as RGBA pass the pixel allowance are refused with a message. Every reader recognizes a Pixar file by its magic number. Tested on synthetic files only (`crates/engine/src/cmd/fileio/tests_pxrread.rs`). - JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. Each axis uses the valid display resolution (`resd`), else the valid capture resolution (`resc`), whichever box comes first ([ITU-T T.800](https://www.itu.int/rec/T-REC-T.800/en), I.5.3.7.2); an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`). @@ -1879,6 +1888,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - With Enable Background Export on, an Export for Screens with no folder of its own also writes in the background into the folder its `exportPreset` keeps, with the same progress dialog and Cancel, and the status bar names that folder. When that folder is outside an MCP client's read roots, the reply, its errors and the status bar name it by its own name, as for an export made at once (`dialogs::tests_screens::an_export_into_a_presets_folder_writes_in_the_background`, `fileio::tests_screens::a_prepared_export_into_a_presets_folder_outside_the_read_root_leaves_it_out`). Behavior reference: the help page, https://helpx.adobe.com/illustrator/using/exporting-artwork.html (exports run in the background by default). - Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected. - The Links panel's Relink… opens a Relink dialog whose Replace menu picks which links take the new file: Selected Instances (the selected images, as before), All Instances (every image linked to the same file), Missing Links from This Folder (then every other missing link whose file name is in the new file's folder) and Each Link Separately (a file for each selected link in turn). Each relinks as one undo step (`links.relink {scope}`, `ui.relinkDialog`). Relink to Folder… stays as it is. + - The Links panel menu's Copy Info ([Links panel](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/links-panel-in-illustrator.html)) copies the selected link's file name to the clipboard (`links.copyInfo`). VectorCraft's own rules: with several rows selected it copies one name per line in the order the panel shows them, an embedded image gives the name its row shows, and the item is off with no row selected. - Linked files inside symbols, graph designs, patterns and opacity masks are saved with their paths relative to the document, as linked files in the layers are, so they are found after the document and its files move to another folder together; on open each of those links takes the path where its file was found. Update Links, and opening with Update Links: Automatically, read a modified file again for that art too, and saving a document refreshes its placed copies inside symbols and patterns of the other open documents. Evidence: `links_outside_the_layers_find_their_files_after_the_folder_moves`, `update_links_reads_files_again_inside_symbols_patterns_and_masks`, `saving_a_document_refreshes_its_placed_copies_inside_symbols_and_patterns`. - A linked file that is missing when a moved document is saved keeps the folder its relative path names, instead of a relative path worked out from the out-of-date absolute path, also for a `.ai` file another app wrote (such as `src/`), so the file is found once it is back in that folder. Evidence: `a_missing_link_keeps_its_relative_path_when_the_moved_document_is_saved`, `links_of_an_imported_file_are_found_by_their_relative_folder`, `a_missing_link_of_a_moved_document_keeps_its_relative_place`. - On Windows, a document opened from a mapped network drive finds its links stored with the share's own path (`\\server\share\…`), as the same document opened from the share does; links on other computers stay missing. @@ -2013,6 +2023,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Moving objects with raster effects (#1257):** during a drag or a dialog's preview, an object whose content only moved draws its cached drop shadows, outer glows and filtered content (Gaussian Blur, Feather and the Photoshop-style filters) from the rasters of an earlier frame at the nearest whole pixel, without running the effects again. The renderer first draws the moved content where the old content lies in its raster and compares the pixels. De-Interlace runs again. The frame after the drag runs the effects again and draws what a fresh renderer draws; on the desktop, the canvas renders that frame on the render worker. `texture_drag_perf` times a drag's frames and the frame after it. - **Glass's Load Texture:** Effect › Distort › Glass… has a Load Texture… button that reads a PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PSD, PSB, PCX, Pixar, JPEG 2000, HEIC/HEIF or JPEG XL file and adds Loaded Texture to the Texture menu (`textureImage`: `{path}` or `{name?, dataBase64}`, stored in the effect and saved with the document as a grayscale PNG in base64). The texture lies one image pixel per point at Scaling 100%, centered on the object and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray. Scaling and Invert act on it as on the built-in textures. A Glass saved without `textureImage` draws and saves as before. +- **Rough Pastels' Load Texture:** Effect › Artistic › Rough Pastels… has the same Load Texture… button, and its Texture and Light Direction controls are menus. The loaded image's gray levels are the paper surface (Loaded Texture in the Texture menu, `textureImage` stored with the effect), and Scaling, Relief, Light Direction and Invert act on it as on Brick, Burlap, Canvas and Sandstone (`pixel::tests::rough_pastels_draws_a_loaded_texture_as_its_surface`, `rough_pastels_loads_a_texture_saves_it_and_draws_it`). - **Bounded effect length inputs (#4638):** Effect dialog fields with defined length ranges (such as Crystallize Cell Size 3..300 pt) clamp immediately on commit and reject invalid inputs visibly with an accent highlight without reverting to sub-minimum values. - **Color Halftone in raster pixels (#3260):** Max Radius is 4–127 px of the Document Raster Effects resolution, as public documentation gives it, shown in px whatever the document units; documents from before open with their radii in points converted (document version 3), and a file saved for an older app version holds them in points again. diff --git a/docs/type-parity.md b/docs/type-parity.md index 57578e454b..fa76290e94 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -34,6 +34,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | | OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table. A double-click applies the OpenType feature that draws a variant to that character alone, or uses a glyph choice when no supported feature draws it; see the Glyphs panel row below for Entire Font and the joining limits. The glyph-cell pop-up of alternates is not there yet | | Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Show › Entire Font applies an unencoded glyph the same way when it is an alternate of the selected character in its word, applies one no feature reaches (`A.salt` in a font with no `salt`) to the selected character as a glyph choice (it may not join), and dims the others with the reason in the tooltip (e.g. `yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is ي (U+064A)`); an Arabic presentation form picked there inserts its letter so the word joins. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the OpenType feature that draws the variant (`salt=N` or `ssNN`) to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature reaches falls back to a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. | +| OpenType panel menu: H or V Style, Roman Italics | done | With Show East Asian Options, as [Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html) describes: H or V Style is the character feature `hkna` (kana in the font's horizontal forms, shaped as `vkna` in vertical type) and Roman Italics is `ital` (the font's italic half-width alphanumerics), set with `text.setStyle` / `text.setRangeStyle {features, featureChanges}`, on or off only, and in Character Style Options › OpenType Features ([OpenType feature list](https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist)). | | Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972); wherever the font menu opens, its filters keep the same four rows, and it opens at least wide enough for them and for a family name up to 220 points wide with its sample (up to 60% of the window), each sample filling its row's height (a VectorCraft layout); a filter chosen in the font menu of the Character panel, the Control bar or the Properties panel is on in the others, and the filters are cleared when the app quits ([Font browser overview](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/font-options-overview.html)); the font menus of the Glyphs panel and of the dialogs that pick a font share the same filters (a VectorCraft choice). | | 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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5f26e4377e..7496d62ff5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -207,6 +207,7 @@ tools, never checked side by side) isn't captured row by row. | Ruler guides align to a key object: a click on an object selected with guides makes it the key (one object is enough); Align moves the selected guides to its edge or centre (vertical guides to left, centre or right, horizontal ones to top, centre or bottom) and the key stays put | done | #2143; aligning to the selection or the artboard leaves guides alone | | Align panel menu › Align to Glyph Bounds › Point Text and Area Text (checked while on): Align and Distribute measure point type or area type by the ink of its glyphs instead of its em box or frame | done | #4231; `alignGlyphBoundsPoint`, `alignGlyphBoundsArea` ([Align text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/align-text.html)); `tests::glyphbounds`, `panels::align::tests::align_to_glyph_bounds_toggles_point_and_area_text`; the Character and Properties panels' Align to Glyph Bounds controls are not done | | Align and distribute artboards: with the Artboard tool chosen, the Align panel's Align Objects, Distribute Objects and Distribute Spacing buttons, and the Control bar's, move the selected artboards with the art inside them (Move Artwork with Artboard) | done | `artboard.align`, `artboard.distribute`, `artboard.distributeSpacing` ([Align and distribute artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/align-and-distribute-artboards.html)); VectorCraft's own rules: Align to Artboard keeps the active artboard in place and sends the Distribute Spacing value, Align to Key Object is off, a locked artboard stays where it is; `cmd::artboardalign::tests`, `panels::align::tests::the_align_buttons_align_the_selected_artboards` | +| Object › Distribute, below Object › Align: Horizontal Distribute Left, Center and Right, Vertical Distribute Top, Center and Bottom, Horizontal Distribute Space and Vertical Distribute Space, each running what a click on its Align panel button runs (Align To, the key object, the Distribute Spacing value and, with the Artboard tool chosen, the selected artboards) | done | The `align.distribute*` commands; the public page names Object › Distribute beside Object › Align for the Align and Distribute commands ([Align and distribute objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/align-and-distribute-objects.html)); the items and their order are VectorCraft's own, taken from the Align panel; `menus::tests::object_distribute_runs_the_distribute_buttons` | | A marquee selects the objects whose paths it touches (anchors or segments): drawn inside a filled shape, over a background, it leaves that shape (Preview and Outline) | done | #1103 | | Click a segment independently of its endpoint anchors; only its direction handles show | done | #1278: hollow endpoint squares, selected edge emphasized; headless egui checked | | Drag a curved segment to bend it, a straight one to move its anchors | done | | @@ -268,7 +269,7 @@ tools, never checked side by side) isn't captured row by row. | Bounding-box handles: Shift proportional, Alt from centre, rotate outside the corners | done | | | Rotated objects keep a rotated bounding box and their angle; Reset Bounding Box | done | | | Vector Halftone Preview keeps the selection box and hides selection marks on the temporary dots | done | #6369; Preview off and Cancel restore the original marks. | -| Free Transform, Scale and Reflect along a rotated box | missing | | +| Free Transform, Scale and Reflect along a rotated box | done | The Scale and Reflect tools, dialogs and commands and the Transform and Properties panels' flips act along the turned box until Reset Bounding Box; `basis: "page"` keeps the page's axes | | Free Transform widget: constrain, free distort, perspective distort; the key-held variants | done | | | Transform tools: click sets the reference point (snaps to anchors/centres), Alt-click opens the dialog, Alt-release copies | done | with anchors selected with Direct Selection, the tools, the dialogs and Move transform those anchors alone, about the centre of their box by default (#1408; the default point and whole-object copies are our choices) | | Move, Rotate, Scale, Reflect and Shear dialogs: live Preview; Up/Down step angles and percentages by 1, Shift by 10 | done | #1412; Preview starts off (our choice: the public documentation gives no default), Move's as it was last confirmed; OK is one undo step and Transform Again repeats it |