From 1d41b403e1d1d1b2e48801d75f749e9f7970c960 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:07:41 -0800 Subject: [PATCH 01/17] Effects: Outline Object on an image's fill or stroke follows its opaque pixels An image's own fill or stroke with Outline Object now follows the outline of the image's pixels that are at least 50% opaque, traced from the alpha and fitted with curves, instead of the image's frame. An image without transparency, one whose pixels can't be read, one of more than 64 megapixels or whose decoded pixels take more than 256 MB, and one whose outline would need more than 100,000 anchor points keep the frame. An image with no pixel at least half opaque gives nothing to paint. Images of more than 4 megapixels are traced from their alpha averaged down to 4 megapixels, and an averaged pixel that covers a pixel at least half opaque stays at least half opaque, so thin opaque lines stay in the outline. Each outline is traced once for its pixels, kept for the next redraw while those pixels are loaded, and dropped after them; a second thread that needs the same outline waits for the trace. - vectorcraft-trace: `decode_alpha` reads an image's alpha, and `opaque_outline` traces it as Black and White mode at threshold 128. - vectorcraft-effects: the reshaped image art uses it for rows whose first effect is Outline Object; Convert to Shape rows keep the frame. - Docs: docs/mcp.md, the Outline Object params text and an evidence line in docs/target-app-parity.md. docs/mcp.md also says that Use Preview Bounds takes in the shapes an image's fills and strokes paint. --- Cargo.lock | 1 + crates/effects/Cargo.toml | 1 + crates/effects/src/catalog.rs | 2 +- crates/effects/src/reshape.rs | 139 ++++++++-- crates/effects/src/tests.rs | 1 + crates/effects/src/tests/imageoutline.rs | 318 +++++++++++++++++++++++ crates/engine/src/tests/containers.rs | 116 +++++++++ crates/trace/src/lib.rs | 2 + crates/trace/src/opaque.rs | 292 +++++++++++++++++++++ crates/trace/src/raster.rs | 2 +- docs/mcp.md | 21 +- docs/target-app-parity.md | 1 + xtask/src/layers.rs | 3 + 13 files changed, 875 insertions(+), 24 deletions(-) create mode 100644 crates/effects/src/tests/imageoutline.rs create mode 100644 crates/trace/src/opaque.rs diff --git a/Cargo.lock b/Cargo.lock index 02e109b6b2..8fb751a697 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6475,6 +6475,7 @@ dependencies = [ "vectorcraft-plugins", "vectorcraft-text", "vectorcraft-three-d", + "vectorcraft-trace", ] [[package]] diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index 4078312855..984c3f4dfa 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -15,6 +15,7 @@ vectorcraft-doc = { workspace = true } vectorcraft-pathops = { workspace = true } vectorcraft-text = { workspace = true } vectorcraft-plugins = { workspace = true } +vectorcraft-trace = { workspace = true } image = { workspace = true } kurbo = { workspace = true } # Effect › SVG Filters draws its filters with resvg (MIT OR Apache-2.0), at the usvg version the diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 866d9b32f3..09cb77a3c3 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -294,7 +294,7 @@ pub fn effect_catalog() -> Vec { "path.outlineObject", "Outline Object", PATH, - "{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change", + "{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change. On an image's own fill or stroke it gives the outline of the image's pixels that are at least half opaque, or the image's frame when it has no transparency", json!({}), ), g( diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index c629cc10ca..e24dde7f5c 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -9,13 +9,14 @@ //! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those //! effects reshapes the whole outline that its fill or stroke paints. An image's own fills and //! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives -//! them ([`image_rows`]). +//! them ([`image_rows`]): Outline Object follows the outline of the image's opaque area +//! ([`opaque_area`]). -use std::collections::HashSet; -use std::sync::Arc; +use std::collections::{BTreeMap, HashSet}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError, Weak}; use vectorcraft_doc::live::Hooks; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, ImageObject, Node, NodeId, NodeKind}; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes}; use crate::{Budget, GeomContext, OutlineHook, apply_one, is_geometry, merged_params}; @@ -161,8 +162,8 @@ pub fn gives_image_shape(effects: &[Effect]) -> bool { /// The fills and strokes of `items` (an image's own) that paint something ([`shapes_image`]), each /// without the effects before its first Convert to Shape or Outline Object. Convert to Shape sizes -/// its shape from the image's box, and Outline Object gives the image's frame. The others paint -/// nothing. +/// its shape from the image's box, and Outline Object gives the outline of the image's opaque +/// area ([`opaque_area`]), or its frame when it has no transparency. The others paint nothing. fn image_rows(items: &[AppearanceItem]) -> Vec { items .iter() @@ -177,11 +178,98 @@ fn image_rows(items: &[AppearanceItem]) -> Vec { .collect() } +/// Does `item`, one of [`image_rows`], start from the outline of the image's opaque area (is its +/// first effect Outline Object rather than Convert to Shape)? +fn follows_opaque_area(item: &AppearanceItem) -> bool { + item.effects().first().is_some_and(|e| e.id == "path.outlineObject") +} + +/// The outline of a set of pixels' opaque area, in the pixel space they decode to, with that size +/// ([`traced_opaque_area`]). `None` for pixels that have no transparency or could not be traced. +type Traced = Option<(Arc, (u32, u32))>; + +/// One of [`Outlines`]' entries: the pixels, held weakly, and their outline once it is traced. +type Entry = (Weak>, Arc>); + +/// The outlines [`opaque_area`] has traced, by the address of the pixels each was traced from. +struct Outlines { + /// Each outline with its pixels. An entry does not keep its pixels, and while it is kept no + /// other pixels can take their address. Entries whose pixels are gone are dropped before a + /// new outline is traced and every [`PRUNE_EVERY`] lookups. + kept: BTreeMap, + /// The lookups since the last pruning. + lookups: usize, +} + +static OUTLINES: Mutex = Mutex::new(Outlines { kept: BTreeMap::new(), lookups: 0 }); + +/// How many lookups [`OUTLINES`] takes between two prunings of its entries for pixels that are gone. +const PRUNE_EVERY: usize = 256; + +#[cfg(test)] +thread_local! { + /// The outlines [`traced_opaque_area`] has traced on this thread. + pub(crate) static TRACES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Does [`OUTLINES`] keep an entry for `pixels`? +#[cfg(test)] +pub(crate) fn keeps_outline(pixels: &Weak>) -> bool { + OUTLINES.lock().unwrap_or_else(PoisonError::into_inner).kept.values().any(|(w, _)| w.ptr_eq(pixels)) +} + +/// The outline of the opaque area of the image `im` in the document +/// ([`vectorcraft_trace::opaque_outline`]: its pixels that are at least half opaque, outlined with +/// fitted curves), its pixels taken from `im` itself or from the first of `docs` that holds them. +/// `None` when the image has no transparency, its pixels can't be read or are too large to read +/// ([`vectorcraft_trace::decode_alpha`]), or its outline would be too complex: Outline Object then +/// follows the image's frame. The outline is traced once for each set of pixels and kept while +/// those pixels are: a caller that asks while another thread traces the same pixels waits for that +/// trace. +pub(crate) fn opaque_area(docs: &[Option<&Document>], im: &ImageObject) -> Option { + let blob = im.pixels.as_deref().or_else(|| docs.iter().flatten().find_map(|d| d.images.get(&im.key)))?; + let bytes = &blob.bytes; + let cell = { + let mut o = OUTLINES.lock().unwrap_or_else(PoisonError::into_inner); + o.lookups += 1; + if o.lookups >= PRUNE_EVERY { + o.lookups = 0; + o.kept.retain(|_, (w, _)| w.strong_count() > 0); + } + let key = Arc::as_ptr(bytes).addr(); + match o.kept.get(&key) { + Some((_, cell)) => Arc::clone(cell), + None => { + o.kept.retain(|_, (w, _)| w.strong_count() > 0); + let cell = Arc::new(OnceLock::new()); + o.kept.insert(key, (Arc::downgrade(bytes), Arc::clone(&cell))); + cell + } + } + }; + // Traced without the lock: another image's outline need not wait for this one. + let (path, (w, h)) = cell.get_or_init(|| traced_opaque_area(bytes)).clone()?; + let to_image = Affine::scale_non_uniform(f64::from(im.width) / f64::from(w.max(1)), f64::from(im.height) / f64::from(h.max(1))); + Some(path.transformed(im.xf * to_image)) +} + +/// [`opaque_area`]'s outline of the encoded image `bytes`. An image whose format has no alpha is +/// not decoded. +fn traced_opaque_area(bytes: &[u8]) -> Traced { + #[cfg(test)] + TRACES.with(|t| t.set(t.get() + 1)); + let (w, h, alpha) = vectorcraft_trace::decode_alpha(bytes).ok()??; + let outline = vectorcraft_trace::opaque_outline(w, h, alpha).ok()??; + Some((Arc::new(outline), (w, h))) +} + /// The shapes that the image `n`'s own fills and strokes paint ([`image_rows`]), each with its fill -/// rule and the fill or stroke that paints it: the paths (or compound paths) along its frame that -/// [`reshape`] makes, with each fill's or stroke's geometry effects applied as the renderer applies -/// them. Empty for anything but an image with such fills or strokes. The swatches and patterns that -/// live-effect plug-ins painting their pieces name are looked up in `doc`. +/// rule and the fill or stroke that paints it: the paths (or compound paths) that [`reshape`] +/// makes along its frame or, for those whose first effect is Outline Object, along the outline of +/// its opaque area ([`opaque_area`]), with each fill's or stroke's geometry effects applied as the +/// renderer applies them. Empty for anything but an image with such fills or strokes. The swatches +/// and patterns that live-effect plug-ins painting their pieces name are looked up in `doc`, and +/// the image's pixels in `doc` when the image does not hold them. pub fn image_paint(doc: Option<&Document>, n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> { if !matches!(n.kind, NodeKind::Image(_)) || !has_item_geometry(n) { return vec![]; @@ -477,9 +565,10 @@ pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping /// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied: /// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped /// frame. Type's own fills and strokes keep their effects on its whole outline. An image's own -/// fills and strokes that paint a shape ([`image_rows`]) become paths along its frame below and -/// above it, reshaped with it, and keep their effects from their first Convert to Shape or Outline -/// Object on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask +/// fills and strokes that paint a shape ([`image_rows`]) become paths below and above it, along +/// its frame or, from Outline Object, along the outline of its opaque area ([`opaque_area`]), +/// reshaped with it, and keep their effects from their first Convert to Shape or Outline Object +/// on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask /// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective /// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts, /// or has no art (a symbol instance without `symbol_art`). A path with [`has_transform_art`] is @@ -556,15 +645,33 @@ fn as_art_opts( } Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image.clone())], clip: true }) }; - // The fills and strokes that paint a shape, as paths along the frame below and above - // the image. + // The fills and strokes that paint a shape, as paths below and above the image: along + // the frame, or for those whose first effect is Outline Object along the outline of + // the image's opaque area (traced only when one of them needs it). A run of either + // kind is one path; a fill or stroke whose outline is empty (an image with no opaque + // pixel) paints nothing. let (below, above) = n.appearance.split_contents(); let (below, above) = (image_rows(below), image_rows(above)); if below.is_empty() && above.is_empty() { clipped() } else { + let opaque = std::cell::OnceCell::new(); let rows = |items: Vec| { - (!items.is_empty()).then(|| Arc::new(Node::path(n.id, frame.clone(), Appearance { items, ..Default::default() }))) + let mut out = vec![]; + let mut items = items.into_iter().peekable(); + while let Some(first) = items.next() { + let follows = follows_opaque_area(&first); + let mut run = vec![first]; + while let Some(next) = items.next_if(|i| follows_opaque_area(i) == follows) { + run.push(next); + } + let path = if follows { opaque.get_or_init(|| opaque_area(&[symbols, paint_doc], im)).as_ref() } else { None }; + let path = path.unwrap_or(&frame); + if !path.is_empty() { + out.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: run, ..Default::default() }))); + } + } + out }; // Without geometry effects, the image is not clipped. let image = if fx.is_empty() { image.clone() } else { clipped() }; diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 532251bd73..03ac3c785e 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes}; use super::*; +mod imageoutline; mod scale; mod transformfx; diff --git a/crates/effects/src/tests/imageoutline.rs b/crates/effects/src/tests/imageoutline.rs new file mode 100644 index 0000000000..73eaa6d26d --- /dev/null +++ b/crates/effects/src/tests/imageoutline.rs @@ -0,0 +1,318 @@ +//! Outline Object on an image's own fill or stroke: the outline of the image's opaque area, for +//! every pixel format with alpha, and the frame for one without. + +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{AppearanceItem, Document, Effect, FillLayer, ImageBlob, ImageObject, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_geom::{Affine, Rect}; + +use crate::*; + +fn fx(id: &str) -> Effect { + Effect { id: id.into(), params: json!({}), visible: true } +} + +/// `img` encoded as `format`. +fn encoded(img: image::DynamicImage, format: image::ImageFormat) -> Vec { + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), format).unwrap(); + out +} + +/// A 100×100 RGBA image, transparent but for an opaque disc of radius 30 in its middle. +fn disc() -> image::DynamicImage { + image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(100, 100, |x, y| { + let r = (f64::from(x) + 0.5 - 50.0).hypot(f64::from(y) + 0.5 - 50.0); + image::Rgba([255, 0, 0, if r <= 30.0 { 255 } else { 0 }]) + })) +} + +/// A document holding the encoded image `bytes` (key "img") and an image object of 100×100 pixels +/// placed at (50, 50) with the appearance items `items`, the image's own row at `contents`. +/// → (document, the image's node). +fn image_doc(bytes: Vec, items: Vec, contents: usize) -> (Document, Node) { + let mut d = Document::new(200.0, 200.0); + d.images.insert("img".into(), ImageBlob::new("image/png", bytes)); + let mut n = Node::new( + NodeId(0), + NodeKind::Image(Box::new(ImageObject { + key: "img".into(), + width: 100, + height: 100, + xf: Affine::translate((50.0, 50.0)), + link: None, + placement: Default::default(), + pixels: None, + })), + ); + n.id = d.alloc_id(); + n.appearance.items = items; + n.appearance.set_contents_at(contents); + let l = d.layers[0].id; + d.insert(Some(l), 0, n.clone()).unwrap(); + (d, n) +} + +/// A PNG of `w × h` pixels with the color type `color` at `depth` bits, the chunks `extra` before +/// its pixels, and the filtered rows `rows` stored without compression. +fn png(w: u32, h: u32, color: u8, depth: u8, extra: &[(&[u8; 4], Vec)], rows: &[u8]) -> Vec { + fn crc(data: &[u8]) -> u32 { + let mut c = !0u32; + for &b in data { + c ^= u32::from(b); + for _ in 0..8 { + c = if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; + } + } + !c + } + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + let mut chunk = |kind: &[u8; 4], data: &[u8]| { + let mut body = kind.to_vec(); + body.extend_from_slice(data); + out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes()); + out.extend_from_slice(&body); + out.extend_from_slice(&crc(&body).to_be_bytes()); + }; + let mut ihdr = [w.to_be_bytes(), h.to_be_bytes()].concat(); + ihdr.extend([depth, color, 0, 0, 0]); + chunk(b"IHDR", &ihdr); + for (kind, data) in extra { + chunk(kind, data); + } + // A zlib stream of stored blocks. + let mut z = vec![0x78, 0x01]; + let blocks: Vec<&[u8]> = if rows.is_empty() { vec![rows] } else { rows.chunks(65_535).collect() }; + for (i, block) in blocks.iter().enumerate() { + let len = u16::try_from(block.len()).unwrap(); + z.push(u8::from(i + 1 == blocks.len())); + z.extend(len.to_le_bytes()); + z.extend((!len).to_le_bytes()); + z.extend_from_slice(block); + } + let (mut a, mut b) = (1u32, 0u32); + for &byte in rows { + a = (a + u32::from(byte)) % 65_521; + b = (b + a) % 65_521; + } + z.extend(((b << 16) | a).to_be_bytes()); + chunk(b"IDAT", &z); + chunk(b"IEND", &[]); + out +} + +fn stroke(effects: Vec) -> AppearanceItem { + let mut s = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0); + s.effects = effects; + AppearanceItem::Stroke(s) +} + +/// The bounds of the paths the image's own fills and strokes paint, below it first. +fn row_bounds(d: &Document, n: &Node) -> Vec { + let art = reshape::reshape(Some(d), n, None).unwrap(); + let mut out = vec![]; + for c in art.children().unwrap() { + if matches!(c.kind, NodeKind::Path { .. }) { + out.push(c.geometric_bounds().unwrap()); + } + } + out +} + +fn near(r: Rect, want: [f64; 4], tol: f64) -> bool { + [r.x0 - want[0], r.y0 - want[1], r.x1 - want[2], r.y1 - want[3]].iter().all(|d| d.abs() < tol) +} + +#[test] +fn outline_object_follows_the_opaque_area_of_an_image_with_alpha() { + let gray_alpha = image::DynamicImage::ImageLumaA8(disc().to_luma_alpha8()); + let deep = image::DynamicImage::ImageRgba16(disc().to_rgba16()); + for (name, img) in [("rgba", disc()), ("gray and alpha", gray_alpha), ("16 bits a channel", deep)] { + let (d, n) = image_doc(encoded(img, image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + let rows = row_bounds(&d, &n); + assert_eq!(rows.len(), 1, "{name}"); + // The disc runs from 70 to 130; the frame from 50 to 150. + assert!(near(rows[0], [70.0, 70.0, 130.0, 130.0], 0.5), "{name}: {:?}", rows[0]); + } +} + +#[test] +fn outline_object_follows_the_frame_of_an_image_without_transparency() { + let opaque = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(100, 100, image::Rgb([0, 0, 255]))); + let solid = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(100, 100, image::Rgba([0, 0, 255, 200]))); + let formats = [ + ("jpeg", encoded(opaque.clone(), image::ImageFormat::Jpeg)), + ("rgb png", encoded(opaque, image::ImageFormat::Png)), + ("rgba png, every pixel more than half opaque", encoded(solid, image::ImageFormat::Png)), + ]; + for (name, bytes) in formats { + let (d, n) = image_doc(bytes, vec![stroke(vec![fx("path.outlineObject")])], 1); + let rows = row_bounds(&d, &n); + assert!(rows.len() == 1 && near(rows[0], [50.0, 50.0, 150.0, 150.0], 1e-9), "{name}: {rows:?}"); + } + // Pixels that can't be read keep the frame too. + let (d, n) = image_doc(b"not an image".to_vec(), vec![stroke(vec![fx("path.outlineObject")])], 1); + assert!(near(row_bounds(&d, &n)[0], [50.0, 50.0, 150.0, 150.0], 1e-9)); +} + +#[test] +fn convert_to_shape_keeps_the_frame_beside_outline_object() { + // Below the image: a fill with Convert to Shape, then a stroke with Outline Object; above it + // a stroke with Convert to Shape before Outline Object, which starts from the frame. + let rect = Effect { id: "convertToShape.rectangle".into(), params: json!({"relative": true, "extraW": 0, "extraH": 0}), visible: true }; + let mut fill = FillLayer::new(Paint::solid(Color::rgb(0.0, 1.0, 0.0))); + fill.effects = vec![rect.clone()]; + let items = vec![AppearanceItem::Fill(fill), stroke(vec![fx("path.outlineObject")]), stroke(vec![rect, fx("path.outlineObject")])]; + let (d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), items, 2); + let rows = row_bounds(&d, &n); + assert_eq!(rows.len(), 3, "{rows:?}"); + assert!(near(rows[0], [50.0, 50.0, 150.0, 150.0], 1e-9), "{rows:?}"); + assert!(near(rows[1], [70.0, 70.0, 130.0, 130.0], 0.5), "{rows:?}"); + assert!(near(rows[2], [50.0, 50.0, 150.0, 150.0], 1e-9), "{rows:?}"); + // The image keeps its place between them. + let art = reshape::reshape(Some(&d), &n, None).unwrap(); + assert!(matches!(art.children().unwrap()[2].kind, NodeKind::Image(_))); +} + +#[test] +fn a_transparent_image_gives_outline_object_nothing_to_paint() { + let clear = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(100, 100, image::Rgba([0, 0, 0, 100]))); + let (d, n) = image_doc(encoded(clear, image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + assert!(row_bounds(&d, &n).is_empty()); + let art = reshape::reshape(Some(&d), &n, None).unwrap(); + assert!(matches!(art.children().unwrap().as_slice(), [only] if matches!(only.kind, NodeKind::Image(_)))); +} + +#[test] +fn the_outline_follows_the_images_own_pixels_and_placement() { + // Pixels kept on the image itself (art made outside a document), placed at half size. + let (_, mut n) = image_doc(vec![], vec![stroke(vec![fx("path.outlineObject")])], 1); + if let NodeKind::Image(im) = &mut n.kind { + im.pixels = Some(Arc::new(ImageBlob::new("image/png", encoded(disc(), image::ImageFormat::Png)))); + im.xf = Affine::translate((10.0, 20.0)) * Affine::scale(0.5); + } + let art = reshape::reshape(None, &n, None).unwrap(); + let b = art.children().unwrap()[0].geometric_bounds().unwrap(); + // The disc runs from 20 to 80 pixels: half that, moved by (10, 20). + assert!(near(b, [20.0, 30.0, 50.0, 60.0], 0.3), "{b:?}"); +} + +#[test] +fn an_image_in_a_group_exports_with_its_outline() { + let (mut d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + let l = d.layers[0].id; + d.remove(n.id).unwrap(); + let g = d.alloc_id(); + d.insert(Some(l), 0, Node::group(g, vec![Arc::new(n)])).unwrap(); + let baked = bake_document(&d).unwrap(); + let mut paths = vec![]; + for layer in &baked.layers { + layer.walk(&mut |c| { + if matches!(c.kind, NodeKind::Path { .. }) && (c.appearance.stroke_width() - 4.0).abs() < 1e-9 { + paths.push(c.geometric_bounds().unwrap()); + } + }); + } + assert!(matches!(paths.as_slice(), [b] if near(*b, [70.0, 70.0, 130.0, 130.0], 0.5)), "{paths:?}"); +} + +/// The bounds of the paths stroked 4 pt in `d` with its effects baked, as exports write it. +fn baked_strokes(d: &Document) -> Vec { + let baked = bake_document(d).unwrap(); + let mut paths = vec![]; + for layer in &baked.layers { + layer.walk(&mut |c| { + if matches!(c.kind, NodeKind::Path { .. }) && (c.appearance.stroke_width() - 4.0).abs() < 1e-9 { + paths.push(c.geometric_bounds().unwrap()); + } + }); + } + paths +} + +#[test] +fn an_image_under_a_clipping_mask_or_with_an_opacity_mask_exports_with_its_outline() { + let disc_outline = |paths: &[Rect]| matches!(paths, [b] if near(*b, [70.0, 70.0, 130.0, 130.0], 0.5)); + // Under a clipping mask that covers the whole image. + let (mut d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + let l = d.layers[0].id; + d.remove(n.id).unwrap(); + let clip = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Default::default()); + let g = d.alloc_id(); + d.insert(Some(l), 0, Node::new(g, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(n)], clip: true })).unwrap(); + let paths = baked_strokes(&d); + assert!(disc_outline(&paths), "{paths:?}"); + // With an opacity mask. + let (mut d, mut n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + let mut white = Node::path(NodeId(999), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Default::default()); + white.appearance.set_fill(Paint::solid(Color::WHITE)); + n.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(Node::group(NodeId(998), vec![Arc::new(white)]), true))); + let id = n.id; + *d.node_mut(id).unwrap() = n; + let paths = baked_strokes(&d); + assert!(disc_outline(&paths), "{paths:?}"); +} + +#[test] +fn outline_object_on_a_fill_and_on_an_indexed_image_follows_the_opaque_area() { + let mut fill = FillLayer::new(Paint::solid(Color::rgb(0.0, 1.0, 0.0))); + fill.effects = vec![fx("path.outlineObject")]; + let (d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![AppearanceItem::Fill(fill)], 1); + let rows = row_bounds(&d, &n); + assert!(matches!(rows.as_slice(), [b] if near(*b, [70.0, 70.0, 130.0, 130.0], 0.5)), "{rows:?}"); + // A palette image whose first color is transparent (a tRNS chunk). + let mut rows = vec![]; + for y in 0..100 { + rows.push(0); + for x in 0..100 { + let r = (f64::from(x) + 0.5 - 50.0).hypot(f64::from(y) + 0.5 - 50.0); + rows.push(u8::from(r <= 30.0)); + } + } + let palette = png(100, 100, 3, 8, &[(b"PLTE", vec![0, 0, 0, 255, 0, 0]), (b"tRNS", vec![0, 255])], &rows); + let (d, n) = image_doc(palette, vec![stroke(vec![fx("path.outlineObject")])], 1); + let rows = row_bounds(&d, &n); + assert!(matches!(rows.as_slice(), [b] if near(*b, [70.0, 70.0, 130.0, 130.0], 0.5)), "{rows:?}"); +} + +#[test] +fn an_image_too_large_or_too_complex_to_outline_keeps_its_frame() { + // 72 megapixels at 8 bits a channel, and 42 at 16: refused before their pixels are read. + for (w, h, depth) in [(9000, 8000, 8), (7000, 6000, 16)] { + let bytes = png(w, h, 6, depth, &[], &[]); + assert!(matches!(vectorcraft_trace::decode_alpha(&bytes), Err(vectorcraft_trace::TraceError::TooLarge { .. })), "{w}×{h}"); + let (d, n) = image_doc(bytes, vec![stroke(vec![fx("path.outlineObject")])], 1); + assert!(matches!(row_bounds(&d, &n).as_slice(), [b] if near(*b, [50.0, 50.0, 150.0, 150.0], 1e-9)), "{w}×{h}"); + } + // A checkerboard of 2 px squares, whose outline would need more than 100,000 anchor points. + let checker = image::RgbaImage::from_fn(640, 640, |x, y| image::Rgba([0, 0, 0, if (x / 2 + y / 2) % 2 == 0 { 255 } else { 0 }])); + let (d, n) = + image_doc(encoded(image::DynamicImage::ImageRgba8(checker), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + assert!(matches!(row_bounds(&d, &n).as_slice(), [b] if near(*b, [50.0, 50.0, 150.0, 150.0], 1e-9))); +} + +#[test] +fn an_outline_is_traced_once_for_its_pixels_and_dropped_with_them() { + let traces = || reshape::TRACES.with(std::cell::Cell::get); + let (d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + let NodeKind::Image(im) = &n.kind else { panic!("not an image") }; + let before = traces(); + let first = reshape::opaque_area(&[Some(&d)], im).unwrap(); + assert_eq!(traces(), before + 1); + for _ in 0..3 { + row_bounds(&d, &n); + } + assert_eq!(reshape::opaque_area(&[Some(&d)], im).unwrap(), first); + assert_eq!(traces(), before + 1); + let pixels = Arc::downgrade(&d.images.get(&im.key).unwrap().bytes); + assert!(reshape::keeps_outline(&pixels)); + // Once the document and its pixels are gone, the next trace drops their entry. + drop(d); + assert!(pixels.upgrade().is_none()); + let (other, _) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![], 1); + reshape::opaque_area(&[Some(&other)], im).unwrap(); + assert_eq!(traces(), before + 2); + assert!(!reshape::keeps_outline(&pixels)); +} diff --git a/crates/engine/src/tests/containers.rs b/crates/engine/src/tests/containers.rs index dd32135c18..437f932724 100644 --- a/crates/engine/src/tests/containers.rs +++ b/crates/engine/src/tests/containers.rs @@ -256,6 +256,122 @@ fn an_images_stroke_with_outline_object_draws_exports_and_expands_along_its_fram assert!(kids[0].appearance.fill_paint().is_none()); } +/// A 100×100 PNG, transparent but for an opaque red disc of radius 30 in its middle. +fn disc_png() -> Vec { + let mut png = vec![]; + image::RgbaImage::from_fn(100, 100, |x, y| { + let r = (f64::from(x) + 0.5 - 50.0).hypot(f64::from(y) + 0.5 - 50.0); + image::Rgba([255, 0, 0, if r <= 30.0 { 255 } else { 0 }]) + }) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + png +} + +#[test] +fn an_images_stroke_with_outline_object_follows_its_opaque_area() { + let mut s = session(); + // The frame runs from 50 to 150, the disc from 70 to 130. + let r = run(&mut s, "file.place", json!({"name": "disc.png", "dataBase64": vectorcraft_format::base64_encode(&disc_png()), "at": [100, 100]})); + let im = NodeId(r["ids"][0].as_u64().unwrap()); + run(&mut s, "appearance.addStroke", json!({})); + run(&mut s, "appearance.setItem", json!({"index": 0, "weight": 8, "color": "#000000"})); + run(&mut s, "effect.apply", json!({"effect": "path.outlineObject", "item": 0})); + // Under the image, the stroke's outer 4 pt shows around the disc, from 66 to 70; nothing runs + // along the frame. + let shade = |s: &Session, x: u32, y: u32| match pixel(s, x, y) { + p if p == RED => "red", + [r, g, b, _] if r.max(g).max(b) < 40 => "black", + [r, g, b, _] if r.min(g).min(b) > 215 => "white", + p => panic!("({x}, {y}) is {p:?}"), + }; + assert_eq!([shade(&s, 64, 100), shade(&s, 67, 100), shade(&s, 100, 100), shade(&s, 100, 132)], ["white", "black", "red", "black"]); + assert_eq!([shade(&s, 49, 100), shade(&s, 52, 100)], ["white", "white"]); + // SVG and PDF hold the disc's outline stroked 8 pt. + let doc = s.doc().unwrap().doc.clone(); + let disc = |b: vectorcraft_geom::Rect| [b.x0 - 70.0, b.y0 - 70.0, b.x1 - 130.0, b.y1 - 130.0].iter().all(|d| d.abs() < 0.5); + let svg = vectorcraft_svg::export(&doc, &Default::default()); + assert!(svg.contains(" Vec { let v = ViewInfo::default(); diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 46c29f4e05..48baeccf83 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -51,6 +51,7 @@ mod gradient; mod group; mod logo; mod mosaic; +mod opaque; mod quantize; mod shapes; mod smooth; @@ -64,6 +65,7 @@ use vectorcraft_geom::PathData; pub use contour::{Component, Loop, trace_mask}; pub use gradient::TracedGradient; pub use mosaic::mosaic; +pub use opaque::{ALPHA_MAX_DECODE_BYTES, OUTLINE_MAX_ANCHORS, OUTLINE_MAX_PIXELS, decode_alpha, opaque_outline}; pub use quantize::{FULL_TONE_MAX, Quantized, TRANSPARENT, denoise, quantize}; pub use shapes::TracedShape; use subpixel::Crack; diff --git a/crates/trace/src/opaque.rs b/crates/trace/src/opaque.rs new file mode 100644 index 0000000000..52e80a368d --- /dev/null +++ b/crates/trace/src/opaque.rs @@ -0,0 +1,292 @@ +//! The outline of an image's opaque area: what Outline Object on an image's own fill or stroke +//! follows. + +use vectorcraft_geom::{Affine, PathData}; + +use crate::raster::{MAX_PIXELS, fit_size, gif_first_frame_pixels}; +use crate::{Mode, Raster, TraceError, TraceParams, trace_within}; + +/// The most pixels [`opaque_outline`] traces: a larger image is reduced to this many first, +/// aspect kept ([`reduced`]), and its outline is scaled back up to the image's size. +pub const OUTLINE_MAX_PIXELS: u64 = 4 << 20; + +/// The most anchor points an outline may have; an image whose outline would have more gives +/// [`TraceError::TooComplex`]. +pub const OUTLINE_MAX_ANCHORS: usize = 100_000; + +/// The most bytes [`decode_alpha`] decodes an image's samples into (256 MB: 64 megapixels at 8 +/// bits a channel, 32 megapixels at 16). +pub const ALPHA_MAX_DECODE_BYTES: u64 = 256 << 20; + +/// The alpha of the encoded image `bytes` (PNG, WebP, GIF, TIFF and the other formats the image +/// reader takes), one byte a pixel, row by row, with the image's width and height. `None` when the +/// image's format or color type has no alpha; the image is then not decoded. An image of more +/// than [`MAX_PIXELS`] (64 megapixels), or whose samples take more than [`ALPHA_MAX_DECODE_BYTES`], +/// gives [`TraceError::TooLarge`] before it is decoded. +pub fn decode_alpha(bytes: &[u8]) -> Result)>, TraceError> { + use image::ImageDecoder; + let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; + let decoder = reader.into_decoder().map_err(decode_err)?; + let (w, h) = decoder.dimensions(); + if w == 0 || h == 0 { + return Err(TraceError::Empty); + } + if !decoder.color_type().has_alpha() { + return Ok(None); + } + let pixels = u64::from(w) * u64::from(h); + let frame = gif_first_frame_pixels(bytes).unwrap_or(0); + if pixels.max(frame) > MAX_PIXELS || decoder.total_bytes() > ALPHA_MAX_DECODE_BYTES { + return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 }); + } + let alpha: Vec = match image::DynamicImage::from_decoder(decoder).map_err(decode_err)? { + image::DynamicImage::ImageRgba8(img) => img.as_raw().as_chunks::<4>().0.iter().map(|p| p[3]).collect(), + image::DynamicImage::ImageLumaA8(img) => img.as_raw().as_chunks::<2>().0.iter().map(|p| p[1]).collect(), + img => img.to_rgba8().as_raw().as_chunks::<4>().0.iter().map(|p| p[3]).collect(), + }; + Ok(Some((w, h, alpha))) +} + +/// The outline of the pixels of a `width × height` image whose `alpha` (one byte a pixel, row by +/// row) is at least half opaque (128 or more), in its pixel space (x right, y down; the image +/// spans `0..width × 0..height`). `None` when every pixel is: the outline is then the image's +/// frame. An image with no such pixel gives an empty outline. +/// +/// The alpha is traced as Image Trace's Black and White mode traces a gray at threshold 128, with +/// its Paths and Corners at their defaults and no area left out as noise: the edge runs where the +/// alpha crosses one half, to a fraction of a pixel along anti-aliased edges, and the outline is +/// fitted with curves. Outer outlines and holes run in opposite directions, and an island in a +/// hole runs as outer outlines do, so the outline fills the same with either fill rule. An image +/// of more than [`OUTLINE_MAX_PIXELS`] is traced at that size ([`reduced`]). +pub fn opaque_outline(width: u32, height: u32, alpha: Vec) -> Result, TraceError> { + let (w, h) = (width, height); + if (w as usize).checked_mul(h as usize).is_none_or(|n| n == 0 || n != alpha.len()) { + return Err(TraceError::Empty); + } + if alpha.iter().all(|&a| a >= 128) { + return Ok(None); + } + if alpha.iter().all(|&a| a < 128) { + return Ok(Some(PathData::default())); + } + let (nw, nh) = fit_size(w, h, OUTLINE_MAX_PIXELS); + let alpha = if (nw, nh) == (w, h) { alpha } else { reduced(&alpha, w, h, nw, nh) }; + // Opaque pixels are black, transparent ones white. + let mut rgba = Vec::with_capacity(alpha.len() * 4); + for a in alpha { + let g = 255 - a; + rgba.extend_from_slice(&[g, g, g, 255]); + } + let gray = Raster::new(nw, nh, rgba)?; + let params = TraceParams { mode: Mode::BlackAndWhite, threshold: 128, ignore_white: true, noise: 0, ..TraceParams::default() }; + let traced = trace_within(&gray, ¶ms, OUTLINE_MAX_ANCHORS)?; + let mut out = PathData::default(); + for p in traced.paths { + out.subpaths.extend(p.path.subpaths); + } + if (nw, nh) != (w, h) { + out = out.transformed(Affine::scale_non_uniform(f64::from(w) / f64::from(nw), f64::from(h) / f64::from(nh))); + } + Ok(Some(out)) +} + +/// The `w × h` `alpha` reduced to `nw × nh` (at most `w × h`): each pixel holds the mean of the +/// block of pixels it covers, and at least 128 when that block holds a pixel of 128 or more, so an +/// opaque line thinner than a block stays inside the outline. Along an edge the outline can then +/// run up to half a block further out than the full-size image's would. +fn reduced(alpha: &[u8], w: u32, h: u32, nw: u32, nh: u32) -> Vec { + let span = |i: u32, n: u32, of: u32| { + let at = |i: u32| usize::try_from(u64::from(i) * u64::from(of) / u64::from(n.max(1))).unwrap_or(usize::MAX); + let start = at(i); + start..at(i + 1).max(start + 1) + }; + let cols: Vec<_> = (0..nw).map(|x| span(x, nw, w)).collect(); + let mut out = Vec::with_capacity(nw as usize * nh as usize); + let (mut sums, mut maxes) = (vec![0u64; cols.len()], vec![0u8; cols.len()]); + for ty in 0..nh { + let rows = span(ty, nh, h); + sums.fill(0); + maxes.fill(0); + for y in rows.clone() { + let Some(row) = alpha.get(y * w as usize..(y + 1) * w as usize) else { break }; + for (x, c) in cols.iter().enumerate() { + for &a in row.get(c.clone()).unwrap_or_default() { + sums[x] += u64::from(a); + maxes[x] = maxes[x].max(a); + } + } + } + for (x, c) in cols.iter().enumerate() { + let count = (rows.len() * c.len()).max(1) as u64; + let mean = u8::try_from((sums[x] + count / 2) / count).unwrap_or(u8::MAX); + out.push(if maxes[x] >= 128 { mean.max(128) } else { mean }); + } + } + out +} + +#[cfg(test)] +mod tests { + use kurbo::Shape; + + use super::*; + + /// The width, height and alpha of `img`, as [`opaque_outline`] takes them. + fn alpha(img: &Raster) -> (u32, u32, Vec) { + (img.width, img.height, img.rgba.as_chunks::<4>().0.iter().map(|p| p[3]).collect()) + } + + fn outline(img: &Raster) -> Result, TraceError> { + let (w, h, a) = alpha(img); + opaque_outline(w, h, a) + } + + /// A `w × h` raster, opaque red inside the disc of radius `r` around the middle and + /// transparent outside it. + fn disc(w: u32, h: u32, r: f64) -> Raster { + let (cx, cy) = (f64::from(w) / 2.0, f64::from(h) / 2.0); + Raster::from_fn(w, h, |x, y| { + let (dx, dy) = (f64::from(x) + 0.5 - cx, f64::from(y) + 0.5 - cy); + if dx.hypot(dy) <= r { [255, 0, 0, 255] } else { [0, 0, 0, 0] } + }) + } + + fn area(p: &PathData) -> f64 { + p.to_bezpath().area().abs() + } + + #[test] + fn the_outline_follows_the_opaque_disc_not_the_frame() { + let outline = outline(&disc(100, 100, 30.0)).unwrap().unwrap(); + let b = outline.bounds().unwrap(); + for (got, want) in [(b.x0, 20.0), (b.y0, 20.0), (b.x1, 80.0), (b.y1, 80.0)] { + assert!((got - want).abs() < 1.0, "{b:?}"); + } + let disc_area = std::f64::consts::PI * 30.0 * 30.0; + assert!((area(&outline) - disc_area).abs() < disc_area * 0.02, "{}", area(&outline)); + // Fitted with curves, not one line per pixel step. + assert!(outline.anchor_count() < 40, "{} anchors", outline.anchor_count()); + } + + #[test] + fn an_opaque_image_keeps_its_frame_and_a_transparent_one_has_no_outline() { + let opaque = Raster::from_fn(10, 10, |_, _| [0, 0, 255, 128]); + assert_eq!(outline(&opaque).unwrap(), None); + let clear = Raster::from_fn(10, 10, |_, _| [0, 0, 255, 127]); + assert!(outline(&clear).unwrap().unwrap().is_empty()); + // One pixel either way. + assert_eq!(outline(&Raster::from_fn(1, 1, |_, _| [9, 9, 9, 255])).unwrap(), None); + assert!(outline(&Raster::from_fn(1, 1, |_, _| [9, 9, 9, 0])).unwrap().unwrap().is_empty()); + // No pixels at all, or an alpha that doesn't match the size. + assert!(opaque_outline(0, 0, vec![]).is_err()); + assert!(opaque_outline(2, 2, vec![0]).is_err()); + } + + #[test] + fn pixels_less_than_half_opaque_are_outside() { + // A 20 px square, opaque, inside a 6 px band of alpha `band`, on a transparent 40 px image. + let framed = |band: u8| { + Raster::from_fn(40, 40, |x, y| { + let d = x.min(y).min(39 - x).min(39 - y); + let alpha = match d { + 10.. => 255, + 4.. => band, + _ => 0, + }; + [0, 0, 0, alpha] + }) + }; + let inner = outline(&framed(127)).unwrap().unwrap().bounds().unwrap(); + assert!((inner.x0 - 10.0).abs() < 0.5 && (inner.x1 - 30.0).abs() < 0.5, "{inner:?}"); + // A half-opaque band is inside; the edge runs through the middle of its outer pixels, + // which are half covered. + let outer = outline(&framed(128)).unwrap().unwrap().bounds().unwrap(); + assert!((outer.x0 - 4.5).abs() < 0.25 && (outer.x1 - 35.5).abs() < 0.25, "{outer:?}"); + let outer = outline(&framed(255)).unwrap().unwrap().bounds().unwrap(); + assert!((outer.x0 - 4.0).abs() < 0.25 && (outer.x1 - 36.0).abs() < 0.25, "{outer:?}"); + } + + #[test] + fn a_hole_in_the_opaque_area_is_a_hole_in_the_outline() { + let ring = Raster::from_fn(60, 60, |x, y| { + let d = (f64::from(x) + 0.5 - 30.0).hypot(f64::from(y) + 0.5 - 30.0); + [0, 0, 0, if (10.0..=25.0).contains(&d) { 255 } else { 0 }] + }); + let outline = outline(&ring).unwrap().unwrap(); + let want = std::f64::consts::PI * (25.0 * 25.0 - 10.0 * 10.0); + assert!((outline.to_bezpath().area().abs() - want).abs() < want * 0.03, "{}", outline.to_bezpath().area()); + assert!(!outline.to_bezpath().contains(kurbo::Point::new(30.0, 30.0))); + assert!(outline.to_bezpath().contains(kurbo::Point::new(30.0, 12.0))); + } + + #[test] + fn the_outline_fills_the_same_with_either_fill_rule() { + // A ring with an opaque island in its hole. + let target = Raster::from_fn(80, 80, |x, y| { + let d = (f64::from(x) + 0.5 - 40.0).hypot(f64::from(y) + 0.5 - 40.0); + [0, 0, 0, if (25.0..=35.0).contains(&d) || d <= 10.0 { 255 } else { 0 }] + }); + let path = outline(&target).unwrap().unwrap().to_bezpath(); + let winding = |x: f64, y: f64| path.winding(kurbo::Point::new(x, y)); + let (ring, hole, island, outside) = (winding(40.0, 10.0), winding(40.0, 20.0), winding(40.0, 40.0), winding(2.0, 2.0)); + // Non-zero and even-odd agree when every winding number is -1, 0 or 1. + assert!(ring.abs() == 1 && island == ring && hole == 0 && outside == 0, "{ring} {hole} {island} {outside}"); + } + + #[test] + fn a_large_image_is_traced_smaller_and_scaled_back() { + let big = disc(2400, 2000, 800.0); + assert!(u64::from(big.width) * u64::from(big.height) > OUTLINE_MAX_PIXELS); + let b = outline(&big).unwrap().unwrap().bounds().unwrap(); + for (got, want) in [(b.x0, 400.0), (b.y0, 200.0), (b.x1, 2000.0), (b.y1, 1800.0)] { + assert!((got - want).abs() < 3.0, "{b:?}"); + } + } + + #[test] + fn a_line_thinner_than_the_reduction_stays_in_a_large_images_outline() { + // 39 megapixels, reduced to 4: each traced pixel covers about 3 × 3. The only opaque + // pixels are a line 1 pixel wide, which averages to about a third opaque. + let (w, h) = (8192u32, 4800u32); + let mut a = vec![0u8; (w * h) as usize]; + for y in 1000..3000 { + a[(y * w + 2001) as usize] = 255; + } + let b = opaque_outline(w, h, a).unwrap().unwrap().bounds().unwrap(); + assert!(b.x0 > 1990.0 && b.x1 < 2012.0 && (b.y0 - 1000.0).abs() < 6.0 && (b.y1 - 3000.0).abs() < 6.0, "{b:?}"); + } + + #[test] + fn an_outline_of_too_many_anchors_is_refused() { + // A checkerboard of 2 px squares: more than 50,000 opaque squares. + let checker = Raster::from_fn(640, 640, |x, y| [0, 0, 0, if (x / 2 + y / 2) % 2 == 0 { 255 } else { 0 }]); + assert!(matches!(outline(&checker), Err(TraceError::TooComplex { max: OUTLINE_MAX_ANCHORS }))); + } + + /// `img` encoded as `format`. + fn encoded(img: image::DynamicImage, format: image::ImageFormat) -> Vec { + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), format).unwrap(); + out + } + + #[test] + fn decode_alpha_reads_the_alpha_of_formats_that_have_one() { + let px = |x: u32, y: u32| image::Rgba([10, 20, 30, u8::try_from((x + 4 * y) * 16).unwrap()]); + let rgba = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(4, 4, px)); + let want: Vec = (0..16).map(|i| u8::try_from(i * 16).unwrap()).collect(); + for (name, img) in [ + ("rgba", rgba.clone()), + ("gray and alpha", image::DynamicImage::ImageLumaA8(rgba.to_luma_alpha8())), + ("16 bits", image::DynamicImage::ImageRgba16(rgba.to_rgba16())), + ] { + let got = decode_alpha(&encoded(img, image::ImageFormat::Png)).unwrap(); + assert_eq!(got, Some((4, 4, want.clone())), "{name}"); + } + let rgb = image::DynamicImage::ImageRgb8(rgba.to_rgb8()); + assert_eq!(decode_alpha(&encoded(rgb.clone(), image::ImageFormat::Png)).unwrap(), None); + assert_eq!(decode_alpha(&encoded(rgb, image::ImageFormat::Jpeg)).unwrap(), None); + assert!(decode_alpha(b"not an image").is_err()); + } +} diff --git a/crates/trace/src/raster.rs b/crates/trace/src/raster.rs index 3e61c58953..5e198e6fb7 100644 --- a/crates/trace/src/raster.rs +++ b/crates/trace/src/raster.rs @@ -59,7 +59,7 @@ pub struct Raster { /// The pixels the first frame of GIF `bytes` declares in its image descriptor, which the decoder /// allocates for before it crops the frame to the logical screen the dimension check reads. `None` /// when the bytes aren't a GIF or hold no frame header. -fn gif_first_frame_pixels(bytes: &[u8]) -> Option { +pub(crate) fn gif_first_frame_pixels(bytes: &[u8]) -> Option { if !bytes.starts_with(b"GIF8") { return None; } diff --git a/docs/mcp.md b/docs/mcp.md index 4ac7a10930..76bca11c4b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3443,12 +3443,21 @@ the fill or stroke paints, and SVG, PDF, EPS and the other vector exports write holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. An image's own fills and strokes draw only when they carry a visible Convert to Shape or Outline Object effect: Convert -to Shape sizes its shape from the image's box, Outline Object gives the image's frame, the effects after it reshape -that shape, and the effects before it are left out. Its Contents row stands for the image; `appearance.addFill` and -`appearance.addStroke` put a new item right below that row, so it paints under the image. SVG, PDF, EPS and the other -vector exports write those items as paths, and `effect.expandAppearance` turns them into objects in a group with the -image. -Use Preview Bounds measures an image by its frame. +to Shape sizes its shape from the image's box, Outline Object gives the outline of the image's opaque area, the +effects after it reshape that shape, and the effects before it are left out. The opaque area is the pixels whose alpha +is at least 50%. Its outline is traced as Image Trace's Black and White mode traces at threshold 128 (Paths and +Corners at their defaults, no area left out as noise): the edge follows anti-aliased edges to a fraction of a pixel +and is fitted with curves. Outline Object gives the image's frame when the image has no transparency (a format +without alpha, or every pixel at least half opaque), when its pixels can't be read, when the outline would take more +than 100,000 anchor points, and when the image has more than 64 megapixels or its decoded pixels take more than 256 MB +(a 16-bit image of more than 32 megapixels). An image with no pixel at least half opaque gives nothing to paint. An +image of more than 4 megapixels is traced with its alpha averaged down to 4 megapixels; an averaged pixel that covers a +pixel at least half opaque counts as at least half opaque, so opaque lines thinner than the reduction stay in the outline. The +outline is traced the first time it is needed and kept while the image's pixels are loaded. These are VectorCraft's +own rules. The image's Contents row stands for the image; `appearance.addFill` and `appearance.addStroke` put a new +item right below that row, so it paints under the image. SVG, PDF, EPS and the other vector exports write those items +as paths, and `effect.expandAppearance` turns them into objects in a group with the image. +Use Preview Bounds measures an image by its frame and the shapes its own fills and strokes paint. ```json {"name":"run_command","arguments":{"command":"appearance.addFill","params":{"ids":[7]}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3cc8df2a9e..133791a977 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1370,6 +1370,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Fill and stroke paint (Fill, Stroke, None, Apply Last Color and Apply Last Gradient, also on characters selected with the Type tool), the Stroke panel's options, gradient and freeform point edits, Swap Fill and Stroke, Default Fill and Stroke, the Eyedropper, and the Appearance panel's item edits, Clear Appearance, Reduce to Basic Appearance, Remove Item, item reordering and Move Appearance record no undo step when they change nothing. The Add New Effect popup (Appearance panel, Properties), the Appearance panel's swatch picker, the Gradient panel's gradient swatches menu and the Image Trace button's list of presets stop at the bottom of a short window, and the mouse wheel or the scroll bar reaches their last rows. A list that fits does not scroll (`a_long_swatch_picker_scrolls_inside_a_short_window`). An image's own fills and strokes draw the shape that a Convert to Shape or Outline Object effect on them gives them (the image's box or its frame, reshaped by the effects after it), under or over the image as its Contents row says, on the canvas, in exports and through Expand Appearance; new fills and strokes on an image go under it. Without one of those effects they draw nothing, and Use Preview Bounds measures an image by its frame. + Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` (vectorcraft-engine). A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case). diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 8a6f5f0256..3777f8fb81 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -85,6 +85,9 @@ pub const INTRA_LAYER_ORDER: &[&[&str]] = &[ &["three-d", "effects"], // Inflate cuts its surface's grid cells to the shape with Pathfinder's booleans. &["pathops", "three-d"], + // Outline Object on an image's fill or stroke follows the outline Image Trace makes of its + // opaque area. + &["trace", "effects"], ]; fn intra_layer_allowed(from: &str, to: &str) -> bool { From 102ad44304915c7ce8b2f2b1cd6ce7fbd6fdc00b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:03:09 -0800 Subject: [PATCH 02/17] Effects: an image's Outline Object outline follows new pixels under the same key The canvas kept the art of an image whose own fill or stroke follows the outline of its pixels for as long as the image object stayed the same. When the document's pixels for the image changed under the same key (Update Link replacing a preview with the linked file's pixels), the stroke kept the old outline while hit testing and Use Preview Bounds used the new one. The renderer now keys that art by the document's pixels for the image too. - vectorcraft-effects: `follows_image_pixels` names such images. - vectorcraft-render: `reads_outside` counts them, and the resources key hashes the address of the document's pixels for them; their walk is not kept between frames. - A test places an off-center opaque area in a smaller copy of the pixels under a turned and reflected placement. - Docs: the Outline Object params text names every case that keeps the frame and the 50% cutoff as VectorCraft's own; docs/mcp.md's `effect.apply` entry for Outline Object, how long an outline is kept and the 16-bit RGBA limit; docs/target-app-parity.md's evidence lines. --- crates/effects/src/catalog.rs | 2 +- crates/effects/src/lib.rs | 4 +-- crates/effects/src/reshape.rs | 11 ++++++++ crates/effects/src/tests/imageoutline.rs | 20 ++++++++++++++ crates/render/src/fx/predicates.rs | 8 +++--- crates/render/src/live.rs | 33 +++++++++++++++++------- crates/render/src/tests/objectfx.rs | 21 +++++++++++++++ docs/mcp.md | 21 +++++++++------ docs/target-app-parity.md | 4 +-- 9 files changed, 99 insertions(+), 25 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 09cb77a3c3..5b76e91160 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -294,7 +294,7 @@ pub fn effect_catalog() -> Vec { "path.outlineObject", "Outline Object", PATH, - "{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change. On an image's own fill or stroke it gives the outline of the image's pixels that are at least half opaque, or the image's frame when it has no transparency", + "{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change. On an image's own fill or stroke it gives the outline of the image's pixels that are at least half opaque (VectorCraft's own cutoff), and nothing when no pixel is; it gives the image's frame when the image has no transparency, its pixels can't be read, or the image or its outline is too large to trace", json!({}), ), g( diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 92ee086147..607e9d43f9 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -80,8 +80,8 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects}; pub use reshape::{ - SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art, - has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, + SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, + has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, }; pub use revolve::{ CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in, diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index e24dde7f5c..c69c13077f 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -184,6 +184,17 @@ fn follows_opaque_area(item: &AppearanceItem) -> bool { item.effects().first().is_some_and(|e| e.id == "path.outlineObject") } +/// Is `n` an image with a fill or stroke of its own that follows the outline of the image's opaque +/// area ([`image_rows`], [`opaque_area`])? The shape that fill or stroke paints then changes with +/// the image's pixels in the document while `n` stays the same. +pub fn follows_image_pixels(n: &Node) -> bool { + matches!(n.kind, NodeKind::Image(_)) + && n.appearance + .items + .iter() + .any(|i| shapes_image(i) && i.effects().iter().find(|e| gives_shape(e)).is_some_and(|e| e.id == "path.outlineObject")) +} + /// The outline of a set of pixels' opaque area, in the pixel space they decode to, with that size /// ([`traced_opaque_area`]). `None` for pixels that have no transparency or could not be traced. type Traced = Option<(Arc, (u32, u32))>; diff --git a/crates/effects/src/tests/imageoutline.rs b/crates/effects/src/tests/imageoutline.rs index 73eaa6d26d..5ef3955254 100644 --- a/crates/effects/src/tests/imageoutline.rs +++ b/crates/effects/src/tests/imageoutline.rs @@ -199,6 +199,26 @@ fn the_outline_follows_the_images_own_pixels_and_placement() { assert!(near(b, [20.0, 30.0, 50.0, 60.0], 0.3), "{b:?}"); } +#[test] +fn an_off_center_outline_turns_and_reflects_with_a_smaller_copy_of_the_pixels() { + // A 120×60 image whose opaque area is the rectangle from (12, 6) to (40, 24), its pixels a + // copy at half size (as a linked image's preview is), turned a quarter and reflected. + let half = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(60, 30, |x, y| { + image::Rgba([255, 0, 0, if (6..20).contains(&x) && (3..12).contains(&y) { 255 } else { 0 }]) + })); + let (d, mut n) = image_doc(encoded(half, image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + let xf = Affine::translate((100.0, 40.0)) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::scale_non_uniform(-1.0, 1.0); + if let NodeKind::Image(im) = &mut n.kind { + (im.width, im.height, im.xf) = (120, 60, xf); + } + let rows = row_bounds(&d, &n); + let want = xf.transform_rect_bbox(Rect::new(12.0, 6.0, 40.0, 24.0)); + // The rectangle runs from (76, 0) to (94, 28); a flip in either direction, or the copy's + // width and height swapped, would move it. + assert!(near(want, [76.0, 0.0, 94.0, 28.0], 1e-9), "{want:?}"); + assert!(rows.len() == 1 && near(rows[0], [want.x0, want.y0, want.x1, want.y1], 0.5), "{rows:?}"); +} + #[test] fn an_image_in_a_group_exports_with_its_outline() { let (mut d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); diff --git a/crates/render/src/fx/predicates.rs b/crates/render/src/fx/predicates.rs index 2592d2b64e..4f14cadfd1 100644 --- a/crates/render/src/fx/predicates.rs +++ b/crates/render/src/fx/predicates.rs @@ -54,13 +54,15 @@ pub(super) fn evaluates_live(n: &Node) -> bool { /// through live objects it evaluates? It can when `n` holds type (laid out in the fonts installed), /// a symbol instance (made from its symbol's art), an object with a 3D effect or a color /// adjustment (shaded or recolored through the color settings), one with Vector Halftone (its -/// dots sampled from everything it draws), or one with a live-effect plug-in that paints its pieces -/// (drawn only while the swatches and patterns they name are in the document), in it or in the -/// opacity masks of the objects in it. +/// dots sampled from everything it draws), one with a live-effect plug-in that paints its pieces +/// (drawn only while the swatches and patterns they name are in the document), or an image with a +/// fill or stroke that follows the outline of its pixels ([`effects::follows_image_pixels`]: the +/// document's pixels for it can change), in it or in the opacity masks of the objects in it. pub(super) fn reads_outside(n: &Node) -> bool { let mut reads = false; n.walk(&mut |c| { reads |= matches!(c.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }) + || effects::follows_image_pixels(c) || effects::has_revolve(c) || effects::has_solid(c) || effects::has_adjustment(c) diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 3f5ebbb2fe..ec9215b90c 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -9,7 +9,8 @@ //! envelope around an instance of it). What the walk behind that key found is kept with the node //! and with the document's symbols and patterns as the walk read them ([`Walk`]). A later frame //! makes the key from what was kept while the node and the document's symbols and patterns stay -//! the same. A walk that found a placed file is not kept. +//! the same. A walk that found a placed file, or an image whose own fills or strokes follow its +//! pixels, is not kept. //! Gradient meshes are tessellated into many small solid-colour quads (vello //! has no mesh shading), with the density chosen from the on-screen patch size; each quad is //! grown by half a device pixel along its edges so neighbours overlap and no antialiasing seams @@ -44,7 +45,8 @@ type Walk = (Weak, Walked, Arc); /// Per-renderer cache of evaluated live objects: (live node, its evaluation, [`Found`]), a /// [`MeshEntry`] per mesh node and subdivision level, and a [`Walk`] per node whose key -/// [`Renderer::live_found`] found without finding a placed file. +/// [`Renderer::live_found`] found without finding a placed file or an image whose own fills or +/// strokes follow its pixels. pub(crate) struct LiveCache { expanded: FrameCache, Expanded, Found)>, meshes: FrameCache<(usize, usize), MeshEntry>, @@ -97,7 +99,8 @@ pub fn expand_live(n: &Node) -> Vec { /// What the evaluation of `n` (a live node, or an object whose effect art the renderer keeps with /// its inputs, see [`Renderer::live_found`]) reads from the document, placed files, /// the fonts, the plug-ins and the color settings, hashed: the art of the symbols, the tilings of -/// the patterns and the reads of the placed files it shows ([`hash_resources`]), the font +/// the patterns, the reads of the placed files it shows and the document's pixels for the images +/// whose own fills or strokes follow them ([`hash_resources`]), the font /// generation ([`vectorcraft_text::FontDb::generation`]) when it shows type or a placed file, the /// plug-in revision ([`crate::effects::plugin_revision`]) and `names` (the hash of the document's /// swatch and pattern names, [`DocInputs`]) when it shows an object with a plug-in effect, since a @@ -105,13 +108,13 @@ pub fn expand_live(n: &Node) -> Vec { /// while the swatches and patterns they name are in the document, and the color settings (`cms`, /// their address). Art is hashed by the addresses of its `Arc`s, which copies of the document /// share: a copy of the document has the same key. It also returns weak references to the reads -/// found, and what the walk found ([`Walked`]) when it found no placed file. +/// found, and what the walk found ([`Walked`]) when it found no placed file and no such image. fn resources_key(doc: &Document, cms: usize, names: u64, n: &Node) -> (u64, Vec>, Option) { let mut h = DefaultHasher::new(); let mut shown = Shown::default(); hash_resources(doc, n, &mut shown, &mut h); let walked = Walked { hash: h.finish(), text: shown.text, plugins: shown.plugins }; - (walked.key(cms, names), shown.reads, (!shown.placed).then_some(walked)) + (walked.key(cms, names), shown.reads, (!shown.placed && !shown.pixels).then_some(walked)) } /// What [`hash_resources`] found in a node: the hash of what it walked, whether it found type or @@ -153,6 +156,10 @@ struct Shown { text: bool, /// Whether a placed file was found. placed: bool, + /// Whether an image whose own fills or strokes follow its pixels was found + /// ([`crate::effects::follows_image_pixels`]): the document's pixels for it can change while + /// the walk's symbols and patterns stay the same. + pixels: bool, /// Whether an object with a visible plug-in effect was found. plugins: bool, /// Weak references to the reads of the placed files found. @@ -163,9 +170,11 @@ struct Shown { /// tilings ([`crate::pattern::hash_tiling`]) of the patterns in the fills and strokes of its /// objects and of type's characters, the address of each symbol instance's art, and the address of /// the read of each placed file that [`vectorcraft_doc::PlacedDocument::art`] shows (none before -/// the file is read). It walks `n` and then, one at a time from a list, the opacity-mask, pattern -/// and symbol art it finds, each pattern and each symbol's art once ([`first_seen`]). `shown` -/// records the type, placed files, reads and plug-in effects found. +/// the file is read), and the address of the document's pixels for each image whose +/// own fills or strokes follow them ([`crate::effects::follows_image_pixels`]). It walks `n` and +/// then, one at a time from a list, the opacity-mask, pattern and symbol art it finds, each pattern +/// and each symbol's art once ([`first_seen`]). `shown` records the type, placed files, reads, +/// such images and plug-in effects found. fn hash_resources<'a>(doc: &'a Document, n: &'a Node, shown: &mut Shown, h: &mut DefaultHasher) { let mut todo = vec![]; let mut next = Some(n); @@ -203,6 +212,12 @@ fn hash_resources<'a>(doc: &'a Document, n: &'a Node, shown: &mut Shown, h: &mut shown.placed = true; } NodeKind::Text(_) => shown.text = true, + // The address of the pixels the document holds for the image, which Update Link + // replaces under the same key ([`DocInputs`] holds them weakly). + NodeKind::Image(im) if crate::effects::follows_image_pixels(c) => { + doc.images.get(&im.key).map(|b| Arc::as_ptr(&b.bytes) as usize).hash(h); + shown.pixels = true; + } _ => {} } if let Some(m) = c.extras().mask.as_deref() { @@ -442,7 +457,7 @@ impl Renderer { /// evaluated live objects, type, symbol art or colors shaded through the color settings. The /// key is made from the [`Walk`] kept for `n` while `doc`'s symbols and patterns are those it /// read; `n` is walked again when they changed, and in every frame when the walk found a - /// placed file. + /// placed file or an image whose own fills or strokes follow its pixels. pub(crate) fn live_found(&mut self, doc: &Document, n: &Arc) -> Found { let inputs = self.doc_inputs(doc); let at = Arc::as_ptr(n) as usize; diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index fe1e5ee1d1..0384225dd5 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -771,6 +771,27 @@ fn rasterize_on_an_image_takes_in_its_strokes_shape() { assert_eq!((lum(img.pixel(8, 35)), lum(img.pixel(12, 35)), img.pixel(35, 35)), (WHITE, 0, [255, 0, 0, 255])); } +#[test] +fn an_images_outline_object_stroke_follows_new_pixels_under_the_same_key() { + // The opaque image's stroke runs along its frame, from 18 to 22. + let mut d = stroked_image_doc(vec![fx("path.outlineObject", json!({}))], false); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let img = r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!((lum(img.pixel(19, 35)), img.pixel(26, 35)), (0, [255, 0, 0, 255])); + // New pixels under the same key, as Update Link stores them, with the node unchanged: an + // opaque disc of radius 8 pixels in the middle. The stroke follows its edge at 26.5. + let disc = image::RgbaImage::from_fn(30, 30, |x, y| { + let r = (f64::from(x) + 0.5 - 15.0).hypot(f64::from(y) + 0.5 - 15.0); + image::Rgba([255, 0, 0, if r <= 8.0 { 255 } else { 0 }]) + }); + let mut png = vec![]; + disc.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + d.images.insert("red".into(), ImageBlob::new("image/png", png)); + let img = r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!((lum(img.pixel(19, 35)), lum(img.pixel(26, 35))), (WHITE, 0)); +} + /// A 40 pt square filled with `fill` carrying effect `id`, in a 100×100 document. fn glowing_square(fill: Color, id: &str, params: Value) -> Document { let mut n = Node::path(NodeId(0), shapes::rectangle(Rect::new(30.0, 30.0, 70.0, 70.0)), Appearance::basic(Paint::solid(fill), Paint::None, 0.0)); diff --git a/docs/mcp.md b/docs/mcp.md index 76bca11c4b..a62a210f28 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -671,7 +671,9 @@ leaves room for a legend label's stored indents, spacing and leading. `effect.apply {"effect": "path.outlineObject"}` (Effect › Path › Outline Object) takes no parameters. Type that carries it is drawn, exported and expanded as its glyph outlines and stays editable type, and the effects after it reshape -those outlines. On paths, compound paths and images it does not change what is drawn. +those outlines. On paths, compound paths and images it does not change what is drawn. On an image's own fill or stroke +it gives the outline of the image's opaque area, or the image's frame, as described under images' fills and strokes +below. Errors (unknown tool, bad arguments, a disabled or failing command) come back as a normal result with `isError: true` and a message. The model can read the message and retry. @@ -3450,13 +3452,16 @@ Corners at their defaults, no area left out as noise): the edge follows anti-ali and is fitted with curves. Outline Object gives the image's frame when the image has no transparency (a format without alpha, or every pixel at least half opaque), when its pixels can't be read, when the outline would take more than 100,000 anchor points, and when the image has more than 64 megapixels or its decoded pixels take more than 256 MB -(a 16-bit image of more than 32 megapixels). An image with no pixel at least half opaque gives nothing to paint. An -image of more than 4 megapixels is traced with its alpha averaged down to 4 megapixels; an averaged pixel that covers a -pixel at least half opaque counts as at least half opaque, so opaque lines thinner than the reduction stay in the outline. The -outline is traced the first time it is needed and kept while the image's pixels are loaded. These are VectorCraft's -own rules. The image's Contents row stands for the image; `appearance.addFill` and `appearance.addStroke` put a new -item right below that row, so it paints under the image. SVG, PDF, EPS and the other vector exports write those items -as paths, and `effect.expandAppearance` turns them into objects in a group with the image. +(a 16-bit RGBA image of more than 32 megapixels). An image with no pixel at least half opaque gives nothing to paint. +An image of more than 4 megapixels is traced with its alpha averaged down to 4 megapixels; an averaged pixel that +covers a pixel at least half opaque counts as at least half opaque, so opaque lines thinner than the reduction stay in +the outline. The outline is traced the first time it is needed and kept for as long as the image's pixels are loaded. +After the pixels are gone, it is dropped when another outline is traced or within the next 256 outline lookups. When +the image's pixels change (`links.update` reading the linked file), the canvas draws the outline of the new pixels. +These are VectorCraft's own rules. The image's Contents row stands for the image; `appearance.addFill` and +`appearance.addStroke` put a new item right below that row, so it paints under the image. SVG, PDF, EPS and the other +vector exports write those items as paths, and `effect.expandAppearance` turns them into objects in a group with the +image. Use Preview Bounds measures an image by its frame and the shapes its own fills and strokes paint. ```json diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 133791a977..c126296ed6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1369,8 +1369,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects. Fill and stroke paint (Fill, Stroke, None, Apply Last Color and Apply Last Gradient, also on characters selected with the Type tool), the Stroke panel's options, gradient and freeform point edits, Swap Fill and Stroke, Default Fill and Stroke, the Eyedropper, and the Appearance panel's item edits, Clear Appearance, Reduce to Basic Appearance, Remove Item, item reordering and Move Appearance record no undo step when they change nothing. The Add New Effect popup (Appearance panel, Properties), the Appearance panel's swatch picker, the Gradient panel's gradient swatches menu and the Image Trace button's list of presets stop at the bottom of a short window, and the mouse wheel or the scroll bar reaches their last rows. A list that fits does not scroll (`a_long_swatch_picker_scrolls_inside_a_short_window`). - An image's own fills and strokes draw the shape that a Convert to Shape or Outline Object effect on them gives them (the image's box or its frame, reshaped by the effects after it), under or over the image as its Contents row says, on the canvas, in exports and through Expand Appearance; new fills and strokes on an image go under it. Without one of those effects they draw nothing, and Use Preview Bounds measures an image by its frame. - Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` (vectorcraft-engine). + An image's own fills and strokes draw the shape that a Convert to Shape or Outline Object effect on them gives them (the image's box, or the outline of the image's opaque area or its frame, reshaped by the effects after it), under or over the image as its Contents row says, on the canvas, in exports and through Expand Appearance; new fills and strokes on an image go under it. Without one of those effects they draw nothing, and Use Preview Bounds then measures an image by its frame. + Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` and `tests::containers::an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area` (vectorcraft-engine), `tests::objectfx::an_images_outline_object_stroke_follows_new_pixels_under_the_same_key` (vectorcraft-render). A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case). From 2b975d620ebb882f28b1142d4f13a249e685001c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 16:02:00 -0800 Subject: [PATCH 03/17] Actions: Insert Select Path stores the selected path as a step The Actions panel menu has Insert Select Path, after Insert Menu Item. With one whole path or compound path selected, it inserts a Select Path step into the action being recorded, or after the selected action or step. Playing the step selects that path again. - New engine command `select.storedPath {id, fingerprint}`. The step stores the path's object id and a fingerprint of its geometry and fill rule ("v1:" and 16 lowercase hex digits, FNV-1a 64). Playback selects the path only while it exists unchanged and can be selected; otherwise the step fails and the selection stays as it was. A path on a locked artboard is refused in isolation mode too. - Capture refuses an empty selection, several objects, selected anchors or segments alone, groups, type, guides, a path selected together with ruler guides, a member of a compound path, and hidden, locked or template art. - New UI command `ui.action.insertSelectPath {set?, action?, step?}`. Step rows show "Select Path". - `.vcactions` files that hold a Select Path step are written as version 4. Reading accepts versions 1 to 4 and refuses a Select Path step with invalid params. - Rows for the new labels and messages in every catalog. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd.rs | 2 +- crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/src/cmd/select.rs | 153 +++++++++++++ crates/engine/src/lib.rs | 1 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/stored_path.rs | 202 +++++++++++++++++ crates/mcp/src/ui_catalog.rs | 6 + crates/ui-egui/src/i18n/bg.tsv | 7 + crates/ui-egui/src/i18n/bs.tsv | 7 + crates/ui-egui/src/i18n/ca.tsv | 7 + crates/ui-egui/src/i18n/cs.tsv | 7 + crates/ui-egui/src/i18n/da.tsv | 7 + crates/ui-egui/src/i18n/de.tsv | 7 + crates/ui-egui/src/i18n/el.tsv | 7 + crates/ui-egui/src/i18n/es.tsv | 7 + crates/ui-egui/src/i18n/fi.tsv | 7 + crates/ui-egui/src/i18n/fr.tsv | 7 + crates/ui-egui/src/i18n/hi.tsv | 7 + crates/ui-egui/src/i18n/hu.tsv | 7 + crates/ui-egui/src/i18n/id.tsv | 7 + crates/ui-egui/src/i18n/it.tsv | 7 + crates/ui-egui/src/i18n/ja.tsv | 7 + crates/ui-egui/src/i18n/ka.tsv | 7 + crates/ui-egui/src/i18n/ko.tsv | 7 + crates/ui-egui/src/i18n/nl.tsv | 7 + crates/ui-egui/src/i18n/pl.tsv | 7 + crates/ui-egui/src/i18n/pt-br.tsv | 7 + crates/ui-egui/src/i18n/ru.tsv | 7 + crates/ui-egui/src/i18n/sv.tsv | 7 + crates/ui-egui/src/i18n/tr.tsv | 7 + crates/ui-egui/src/i18n/uk.tsv | 7 + crates/ui-egui/src/i18n/vi.tsv | 7 + crates/ui-egui/src/i18n/zh-hans.tsv | 7 + crates/ui-egui/src/i18n/zh-hant.tsv | 7 + crates/ui-egui/src/menus/run.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 6 + crates/ui-egui/src/panels/actions.rs | 214 +++++++++++++++++- crates/ui-egui/src/panels/actions/files.rs | 40 +++- docs/mcp.md | 20 ++ docs/target-app-parity.md | 1 + 40 files changed, 824 insertions(+), 13 deletions(-) create mode 100644 crates/engine/src/tests/stored_path.rs diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 66eb2104d1..1a0cf10846 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -82,7 +82,7 @@ pub mod rasterfx; mod recolor; pub mod recovery; pub mod scripts; -mod select; +pub(crate) mod select; pub(crate) mod shaper; pub mod shortcutset; pub(crate) mod slices; diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index fff7289352..9254022a4a 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -523,6 +523,7 @@ pub(super) const ALLOWED: &[&str] = &[ "select.same.textStrokeColor", "select.segments", "select.set", + "select.storedPath", "select.toggle", "select.update", "shape.arc", diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index a2c7b3a8d4..e1e39d9b48 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -48,6 +48,15 @@ pub fn specs() -> Vec { has_doc, set ), + cmd!( + "select.storedPath", + "Select Path", + [], + None, + "{id: object id, fingerprint: text (\"v1:\" and 16 lowercase hex digits)} the Actions panel's Select Path step: select only the path or compound path `id` when its fingerprint (a hash of its kind, fill rule and anchors, its members' for a compound path) is still `fingerprint` and it can be selected: not hidden or locked, nor in a hidden or locked layer or group, a template layer or on a locked artboard, and inside the isolated object in isolation mode. A missing object, another kind of object, a path whose kind, fill rule or anchors changed, or one that cannot be selected is an error, and the selection stays as it was → {count, ids} (the resulting selection)", + has_doc, + stored_path + ), cmd!( "select.add", "Add to Selection", @@ -439,6 +448,150 @@ fn selection_result(s: &Session) -> Result { Ok(json!({ "count": selection.len(), "ids": selection.objects })) } +/// The prefix of every fingerprint [`path_fingerprint`] gives: the version of its hash. +const FINGERPRINT_PREFIX: &str = "v1:"; + +/// FNV-1a (64 bits) over the bytes written to it, starting from the offset basis. +pub(crate) struct Fnv(pub(crate) u64); + +impl Default for Fnv { + fn default() -> Self { + Self(0xcbf2_9ce4_8422_2325) + } +} + +impl std::io::Write for Fnv { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0 = buf.iter().fold(self.0, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3)); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// The fingerprint of the path or compound path `n` that `select.storedPath` compares: FNV-1a (64 +/// bits) over a kind byte, the fill rule and the anchors of the path, or of each member of the +/// compound path in order, as JSON, written as [`FINGERPRINT_PREFIX`] and 16 lowercase hex digits. +/// None for any object [`is_storable_kind`] does not take. +pub fn path_fingerprint(n: &Node) -> Option { + fn feed(h: &mut Fnv, n: &Node) -> Option<()> { + use std::io::Write; + match &n.kind { + NodeKind::Path { path, rule, .. } => { + h.write_all(b"P").ok()?; + serde_json::to_writer(&mut *h, rule).ok()?; + serde_json::to_writer(&mut *h, path).ok() + } + NodeKind::Compound { children, rule } => { + h.write_all(b"C").ok()?; + serde_json::to_writer(&mut *h, rule).ok()?; + h.write_all(&(children.len() as u64).to_le_bytes()).ok()?; + children.iter().try_for_each(|c| feed(h, c)) + } + _ => None, + } + } + if !is_storable_kind(n) { + return None; + } + let mut h = Fnv::default(); + feed(&mut h, n)?; + Some(format!("{FINGERPRINT_PREFIX}{:016x}", h.0)) +} + +/// Whether `n` is an object a Select Path step can store: a path that is not a guide, or a compound +/// path whose members all are. +fn is_storable_kind(n: &Node) -> bool { + let path = |n: &Node| matches!(n.kind, NodeKind::Path { guide: false, .. }); + match &n.kind { + NodeKind::Compound { children, .. } => children.iter().all(|c| path(c)), + _ => path(n), + } +} + +/// The `id` and `fingerprint` of `select.storedPath`'s params `p`, or why they are not ones: an +/// object with only a non-negative whole `id` and a `fingerprint` that [`path_fingerprint`] could give. +pub fn stored_path_params(p: &Value) -> std::result::Result<(NodeId, &str), String> { + let o = p.as_object().ok_or("`params` must be an object")?; + if let Some(key) = o.keys().find(|k| !matches!(k.as_str(), "id" | "fingerprint")) { + let keys = "id, fingerprint"; + return Err(format!("unknown parameter `{key}`; the command takes: {keys}")); + } + let value = o.get("id").unwrap_or(&Value::Null); + let id = value.as_u64().map(NodeId).ok_or_else(|| format!("invalid object id {value}: expected a non-negative integer"))?; + let fingerprint = o.get("fingerprint").and_then(Value::as_str).unwrap_or_default(); + let hex = fingerprint.strip_prefix(FINGERPRINT_PREFIX).unwrap_or_default(); + if hex.len() != 16 || !hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) { + return Err("`fingerprint` must be \"v1:\" and 16 lowercase hex digits".into()); + } + Ok((id, fingerprint)) +} + +/// Why the object `id` of `st` cannot be the path of a Select Path step: it is not there, is not a +/// path (not a guide) or compound path ([`is_storable_kind`]), is a member of a compound path, cannot +/// be selected ([`is_selectable`]), or is on a locked artboard, in isolation mode too. +fn storable_path(st: &DocState, id: NodeId) -> std::result::Result<(), String> { + let value = id.0; + let n = st.doc.node(id).ok_or_else(|| format!("no such object id {value}"))?; + let in_compound = st.doc.parent_of(id).and_then(|p| st.doc.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. })); + if !is_storable_kind(n) || in_compound { + return Err(WHOLE_PATH.into()); + } + // In isolation mode `is_selectable` does not look at the locked artboards, so they are checked here too. + let locks = st.doc.locked_artboards(); + if !is_selectable(&st.doc, st.isolation, &locks, id) || locks.holds_id(id) { + return Err("the path is hidden or locked, or cannot be selected here".into()); + } + Ok(()) +} + +/// What [`storable_selection`] asks for when the selection is not a whole path or compound path. +const WHOLE_PATH: &str = "select one whole path or compound path"; + +/// The one object selected in `st` when it can be the path of a Select Path step: a whole path or +/// compound path that can be selected ([`storable_path`]). An error for no selection, several +/// objects, a path with ruler guides, anchors or segments of a path alone, a group, type or any other object, a guide, or a +/// member of a compound path. It does not hash the path, so menus can ask it every frame. +pub fn storable_selection(st: &DocState) -> std::result::Result { + let sel = &st.selection; + let id = match sel.objects.as_slice() { + [] => return Err("select a path".into()), + [id] if sel.guides.is_empty() => *id, + _ => return Err("select only one path".into()), + }; + if sel.anchors.get(&id).is_some_and(|a| !a.is_empty()) || sel.segments.get(&id).is_some_and(|s| !s.is_empty()) { + return Err(WHOLE_PATH.into()); + } + storable_path(st, id)?; + Ok(id) +} + +/// The params of a `select.storedPath` step for the path selected in `st` ([`storable_selection`]): +/// `{id, fingerprint}`. +pub fn stored_path_step(st: &DocState) -> std::result::Result { + let id = storable_selection(st)?; + let fingerprint = st.doc.node(id).and_then(path_fingerprint).ok_or(WHOLE_PATH)?; + Ok(json!({ "id": id.0, "fingerprint": fingerprint })) +} + +/// `select.storedPath {id, fingerprint}`: selects only the path `id` when it is still the path the +/// step stored ([`path_fingerprint`]) and can be selected ([`storable_path`]); else an error, and +/// the selection stays as it was. +fn stored_path(s: &mut Session, p: &Value) -> Result { + const C: &str = "select.storedPath"; + let (id, fingerprint) = stored_path_params(p).map_err(|e| bad(C, e))?; + let st = s.doc()?; + let value = id.0; + let n = st.doc.node(id).ok_or_else(|| bad(C, format!("no such object id {value}")))?; + if path_fingerprint(n).as_deref() != Some(fingerprint) { + return Err(bad(C, format!("object {value} is not the stored path: it changed, or the document is another one"))); + } + storable_path(st, id).map_err(|e| bad(C, e))?; + s.select(|_, sel| sel.set([id]))?; + selection_result(s) +} + fn key(s: &mut Session, p: &Value) -> Result { let id = p.get("id").map(|value| checked_id(s, value, "select.key")).transpose()?; let guide = match p.get("guide").filter(|v| !v.is_null()) { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a891fb9599..0a5eeaa65c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -32,6 +32,7 @@ pub use cmd::distortcmds::perspective_click; pub use cmd::params::{check_keys, check_params}; pub use cmd::pathops::auto_simplify_level; pub use cmd::rasterfx::{export_pdf, flatten_raster_effects}; +pub use cmd::select::{path_fingerprint, storable_selection, stored_path_params, stored_path_step}; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use cmd::{EyedropperOptions, PathfinderOptions}; pub use journal::Journal; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 18eadcdf80..36c61b411b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -175,6 +175,7 @@ mod smartguides; mod smooth_path; mod spelling; mod store; +mod stored_path; mod strokegeom; mod strokegradient; mod strokereach; diff --git a/crates/engine/src/tests/stored_path.rs b/crates/engine/src/tests/stored_path.rs new file mode 100644 index 0000000000..3e3bd4ef95 --- /dev/null +++ b/crates/engine/src/tests/stored_path.rs @@ -0,0 +1,202 @@ +//! `select.storedPath`, the Actions panel's Select Path step: the step stores a path's id and a +//! fingerprint of it, and playback selects that path only while it is still there unchanged and +//! can be selected. + +use std::io::Write; + +use serde_json::{Value, json}; + +use crate::*; + +/// A document with two rectangles and an ellipse, nothing selected → the session and their ids. +fn doc() -> (Session, [u64; 3]) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let mut add = |shape: &str, x: u32| s.execute(shape, &json!({"x": x, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap(); + let ids = [add("shape.rectangle", 10), add("shape.rectangle", 80), add("shape.ellipse", 150)]; + s.execute("select.none", &json!({})).unwrap(); + (s, ids) +} + +fn select(s: &mut Session, ids: &[u64]) { + s.execute("select.set", &json!({"ids": ids})).unwrap(); +} + +fn selected(s: &Session) -> Vec { + s.doc().unwrap().selection.objects.iter().map(|id| id.0).collect() +} + +/// The Select Path step's params for `id`, selected alone. +fn step_for(s: &mut Session, id: u64) -> Value { + select(s, &[id]); + stored_path_step(s.doc().unwrap()).unwrap() +} + +#[test] +fn the_hash_is_fnv_1a_64() { + // The published FNV-1a 64-bit test values for "", "a" and "foobar". + for (bytes, want) in [(&b""[..], 0xcbf2_9ce4_8422_2325_u64), (b"a", 0xaf63_dc4c_8601_ec8c), (b"foobar", 0x8594_4171_f739_67e8)] { + let mut h = crate::cmd::select::Fnv::default(); + h.write_all(bytes).unwrap(); + assert_eq!(h.0, want, "{bytes:?}"); + } +} + +#[test] +fn the_step_stores_the_id_and_a_fingerprint_and_plays_by_selecting_the_path() { + let (mut s, [a, b, _]) = doc(); + let p = step_for(&mut s, a); + assert_eq!(p["id"], json!(a)); + let fingerprint = p["fingerprint"].as_str().unwrap(); + assert!(fingerprint.starts_with("v1:") && fingerprint.len() == 19, "{fingerprint}"); + assert_eq!(step_for(&mut s, a), p, "the same path gives the same step"); + assert_ne!(step_for(&mut s, b)["fingerprint"], p["fingerprint"], "another path gives another fingerprint"); + select(&mut s, &[b]); + let r = s.execute("select.storedPath", &p).unwrap(); + assert_eq!(r, json!({"count": 1, "ids": [a]})); + assert_eq!(selected(&s), [a], "only the stored path is selected"); +} + +#[test] +fn a_compound_path_is_one_step_and_a_path_in_a_group_is_one_too() { + let (mut s, [a, b, c]) = doc(); + select(&mut s, &[a, b]); + let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + let p = step_for(&mut s, compound); + let member = s.doc().unwrap().doc.children(Some(vectorcraft_doc::NodeId(compound))).unwrap()[0].id.0; + select(&mut s, &[member]); + assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select one whole path or compound path", "a member alone is not stored"); + s.execute("select.none", &json!({})).unwrap(); + s.execute("select.storedPath", &p).unwrap(); + assert_eq!(selected(&s), [compound]); + + select(&mut s, &[c, compound]); + let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select one whole path or compound path", "a group is not stored"); + let inner = step_for(&mut s, c); + select(&mut s, &[group]); + s.execute("select.storedPath", &inner).unwrap(); + assert_eq!(selected(&s), [c], "the path inside the group is selected alone"); +} + +#[test] +fn only_one_whole_path_that_can_be_selected_is_stored() { + let (mut s, [a, b, _]) = doc(); + let whole = "select one whole path or compound path"; + assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select a path"); + select(&mut s, &[a, b]); + assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select only one path"); + s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); + select(&mut s, &[a]); + s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); + assert_eq!(s.doc().unwrap().selection.guides.len(), 1, "the guide is selected with the path"); + assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select only one path", "a path with a guide"); + s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); + assert_eq!(stored_path_step(s.doc().unwrap()).unwrap(), step_for(&mut s, a), "the path alone again"); + s.execute("select.anchors", &json!({"id": a, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), whole, "an anchor alone"); + let t = s.execute("text.create", &json!({"x": 10, "y": 200, "text": "Type"})).unwrap()["id"].as_u64().unwrap(); + select(&mut s, &[t]); + assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), whole, "type"); + select(&mut s, &[a]); + s.execute("object.lock", &json!({})).unwrap(); + let mut st = s.doc().unwrap().clone(); + st.selection.objects = vec![vectorcraft_doc::NodeId(a)]; + assert_eq!(stored_path_step(&st).unwrap_err(), "the path is hidden or locked, or cannot be selected here"); + let guide = { + let mut d = (*st.doc).clone(); + if let Some(vectorcraft_doc::NodeKind::Path { guide, .. }) = d.node_mut(vectorcraft_doc::NodeId(a)).map(|n| &mut n.kind) { + *guide = true; + } + d + }; + assert!(path_fingerprint(guide.node(vectorcraft_doc::NodeId(a)).unwrap()).is_none(), "a guide is not stored"); +} + +#[test] +fn a_changed_missing_hidden_or_other_path_is_an_error_and_keeps_the_selection() { + let (mut s, [a, b, c]) = doc(); + let p = step_for(&mut s, a); + let fails = |s: &mut Session, p: &Value, want: &str| { + select(s, &[c]); + let e = s.execute("select.storedPath", p).unwrap_err().to_string(); + assert!(e.contains(want), "{e}"); + assert_eq!(selected(s), [c], "the selection stays as it was"); + }; + select(&mut s, &[a]); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + fails(&mut s, &p, "is not the stored path"); + let moved = step_for(&mut s, a); + s.execute("object.hide", &json!({})).unwrap(); + fails(&mut s, &moved, "hidden or locked"); + s.execute("object.showAll", &json!({})).unwrap(); + let shown = step_for(&mut s, b); + s.execute("edit.clear", &json!({})).unwrap(); + fails(&mut s, &shown, "no such object id"); + // Another document: the id names another path there, or nothing. + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 7, "height": 9})).unwrap(); + let c2 = s.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 9, "height": 7})).unwrap()["id"].as_u64().unwrap(); + select(&mut s, &[c2]); + assert!(s.execute("select.storedPath", &moved).is_err()); + assert_eq!(selected(&s), [c2]); +} + +#[test] +fn a_path_on_a_locked_artboard_is_refused_in_isolation_mode_too() { + let (mut s, [a, b, _]) = doc(); + select(&mut s, &[a, b]); + let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + let p = step_for(&mut s, a); + s.execute("select.none", &json!({})).unwrap(); + s.execute("object.isolate", &json!({"id": group})).unwrap(); + s.execute("artboard.new", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + assert!(s.doc().unwrap().isolation.is_some(), "the group stays isolated"); + assert!(s.doc().unwrap().doc.on_locked_artboard(vectorcraft_doc::NodeId(a)), "the path is on the locked artboard"); + let mut st = s.doc().unwrap().clone(); + st.selection.objects = vec![vectorcraft_doc::NodeId(a)]; + assert_eq!(stored_path_step(&st).unwrap_err(), "the path is hidden or locked, or cannot be selected here", "capture"); + let before = selected(&s); + let e = s.execute("select.storedPath", &p).unwrap_err().to_string(); + assert!(e.contains("hidden or locked"), "{e}"); + assert_eq!(selected(&s), before, "the selection stays as it was"); +} + +#[test] +fn a_failing_step_in_a_batch_undoes_the_steps_before_it() { + let (mut s, [a, _, c]) = doc(); + let p = step_for(&mut s, a); + select(&mut s, &[a]); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + select(&mut s, &[c]); + let before = s.doc().unwrap().doc.clone(); + let steps = [json!({"command": "object.move", "params": {"dx": 1, "dy": 0}}), json!({"command": "select.storedPath", "params": p})]; + assert!(s.execute("command.batch", &json!({"commands": steps})).is_err()); + assert!(s.doc().unwrap().doc == before, "the move before the step is undone"); +} + +#[test] +fn params_are_an_id_and_a_v1_fingerprint() { + let (mut s, [a, ..]) = doc(); + let p = step_for(&mut s, a); + let f = p["fingerprint"].clone(); + for bad in [ + json!({"fingerprint": f}), + json!({"id": -1, "fingerprint": f}), + json!({"id": "1", "fingerprint": f}), + json!({"id": a}), + json!({"id": a, "fingerprint": 5}), + json!({"id": a, "fingerprint": "v2:0123456789abcdef"}), + json!({"id": a, "fingerprint": "v1:0123456789ABCDEF"}), + json!({"id": a, "fingerprint": "v1:0123456789abcde"}), + json!({"id": a, "fingerprint": f, "extra": 1}), + json!([a]), + ] { + assert!(stored_path_params(&bad).is_err(), "{bad}"); + s.execute("select.none", &json!({})).unwrap(); + assert!(s.execute("select.storedPath", &bad).is_err(), "{bad}"); + assert!(selected(&s).is_empty(), "{bad}"); + } + assert!(stored_path_params(&p).is_ok()); +} diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index e449bd52cc..35913573be 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1433,6 +1433,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{set?: index, action?: index, step?: index, command, params?: object} insert the command of a menu item as a step, without running it; the step plays as choosing the item runs it. command and params (default {}) must be those of a menu item that can be a step: one that runs an engine command, whose label does not end with \"…\", outside the VectorCraft, File, Window and Help menus, and that is not Undo, Redo, a Paste, a batch (command.batch), a preference (prefs.*) or action (action.*) command, an Object › Align item, Image Trace's Make or Make and Expand, Pattern's Make or Edit Pattern, a saved selection, saved view or recent font item, or a plug-in's item. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. A command and params that no such menu item has, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Menu Item… (dialog `actionInsertMenuItem` with find, command, params, set, action and step) runs this on OK → {set, action, step}, or {recording: true}", ), + ( + "ui.action.insertSelectPath", + "Insert Select Path", + "", + "{set?: index, action?: index, step?: index} insert a Select Path step for the path selected in the active document, without changing the selection: the step `select.storedPath {id, fingerprint}` stores the path's id and a fingerprint of its kind, fill rule and anchors, and playback selects only that path while it is still there unchanged and can be selected, else the step is an error. The selection must be one whole path or compound path that can be selected: not anchors or segments alone, several objects, a group, type or another kind of object, a guide, a member of a compound path, or a hidden or locked object. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. No document, a selection that is not such a path, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Select Path runs this, for the selected step or action or the recording in progress → {set, action, step}, or {recording: true}", + ), ( "ui.action.continue", "Continue Action", diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 582a927f09..f69cd07e32 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5976,6 +5976,13 @@ axis Y Axis Y ос @msg Batch cannot play an action with a stop Пакет не може да изпълни действие със спиране Insert Menu Item… Вмъкване на елемент от меню… Insert Menu Item Вмъкване на елемент от меню + Insert Select Path Вмъкване на избор на път + Select Path Избор на път +@msg select one whole path or compound path изберете един цял път или съставен път +@msg select only one path изберете само един път +@msg the path is hidden or locked, or cannot be selected here пътят е скрит или заключен, или не може да бъде избран тук +@msg object {value} is not the stored path: it changed, or the document is another one обект {value} не е запазеният път: той е променен или документът е друг +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` трябва да е "v1:" и 16 шестнадесетични цифри с малки букви Menu Item: Елемент от меню: No menu item matches. Няма съвпадащ елемент от меню. Several menu items match. Choose one from the list. Съвпадат няколко елемента от меню. Изберете един от списъка. diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index d92da7ae40..ea38155135 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6018,6 +6018,13 @@ axis Y Axis Y osa @msg Batch cannot play an action with a stop Paketna obrada ne može reprodukovati akciju sa zaustavljanjem Insert Menu Item… Umetni stavku menija… Insert Menu Item Umetni stavku menija + Insert Select Path Umetni odabir putanje + Select Path Odabir putanje +@msg select one whole path or compound path odaberite jednu cijelu putanju ili složenu putanju +@msg select only one path odaberite samo jednu putanju +@msg the path is hidden or locked, or cannot be selected here putanja je skrivena ili zaključana, ili se ovdje ne može odabrati +@msg object {value} is not the stored path: it changed, or the document is another one objekt {value} nije sačuvana putanja: promijenjena je ili je dokument drugi +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` mora biti "v1:" i 16 malih heksadecimalnih cifara Menu Item: Stavka menija: No menu item matches. Nijedna stavka menija ne odgovara. Several menu items match. Choose one from the list. Odgovara više stavki menija. Izaberite jednu sa liste. diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 283dae1800..f3334536b3 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -4032,6 +4032,13 @@ digits Malayalam Malaiàlam @msg Batch cannot play an action with a stop Lot no pot reproduir una acció amb una aturada Insert Menu Item… Insereix un element de menú… Insert Menu Item Insereix un element de menú + Insert Select Path Insereix una selecció de traçat + Select Path Selecciona el traçat +@msg select one whole path or compound path selecciona un traçat sencer o un traçat compost +@msg select only one path selecciona només un traçat +@msg the path is hidden or locked, or cannot be selected here el traçat està amagat o bloquejat, o no es pot seleccionar aquí +@msg object {value} is not the stored path: it changed, or the document is another one l’objecte {value} no és el traçat desat: ha canviat o el document és un altre +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` ha de ser "v1:" i 16 dígits hexadecimals en minúscula Menu Item: Element de menú: No menu item matches. Cap element de menú no coincideix. Several menu items match. Choose one from the list. Coincideixen diversos elements de menú. Trieu-ne un de la llista. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 519963c4eb..ea730781d9 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6338,6 +6338,13 @@ axis Y Axis osa Y @msg Batch cannot play an action with a stop Dávka nemůže přehrát akci se zastavením Insert Menu Item… Vložit položku nabídky… Insert Menu Item Vložit položku nabídky + Insert Select Path Vložit výběr cesty + Select Path Vybrat cestu +@msg select one whole path or compound path vyberte jednu celou cestu nebo složenou cestu +@msg select only one path vyberte jen jednu cestu +@msg the path is hidden or locked, or cannot be selected here cesta je skrytá nebo zamčená, nebo ji zde nelze vybrat +@msg object {value} is not the stored path: it changed, or the document is another one objekt {value} není uložená cesta: změnila se, nebo jde o jiný dokument +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` musí být "v1:" a 16 šestnáctkových číslic malými písmeny Menu Item: Položka nabídky: No menu item matches. Žádná položka nabídky neodpovídá. Several menu items match. Choose one from the list. Odpovídá několik položek nabídky. Vyberte jednu ze seznamu. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 3269755003..e9519ab9db 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5993,6 +5993,13 @@ axis Y Axis Y-akse @msg Batch cannot play an action with a stop Batch kan ikke afspille en handling med et stop Insert Menu Item… Indsæt menupunkt… Insert Menu Item Indsæt menupunkt + Insert Select Path Indsæt kurvemarkering + Select Path Markér kurve +@msg select one whole path or compound path markér én hel kurve eller sammensat kurve +@msg select only one path markér kun én kurve +@msg the path is hidden or locked, or cannot be selected here kurven er skjult eller låst, eller kan ikke markeres her +@msg object {value} is not the stored path: it changed, or the document is another one objekt {value} er ikke den gemte kurve: den er ændret, eller dokumentet er et andet +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` skal være "v1:" og 16 hexadecimale cifre med små bogstaver Menu Item: Menupunkt: No menu item matches. Intet menupunkt passer. Several menu items match. Choose one from the list. Flere menupunkter passer. Vælg et fra listen. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f3858abc1f..e57dda183c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5929,6 +5929,13 @@ axis Y Axis Y-Achse @msg Batch cannot play an action with a stop Die Stapelverarbeitung kann keine Aktion mit einem Stopp abspielen Insert Menu Item… Menübefehl einfügen… Insert Menu Item Menübefehl einfügen + Insert Select Path Pfadauswahl einfügen + Select Path Pfad auswählen +@msg select one whole path or compound path wählen Sie einen ganzen Pfad oder zusammengesetzten Pfad aus +@msg select only one path wählen Sie nur einen Pfad aus +@msg the path is hidden or locked, or cannot be selected here der Pfad ist ausgeblendet oder gesperrt oder kann hier nicht ausgewählt werden +@msg object {value} is not the stored path: it changed, or the document is another one Objekt {value} ist nicht der gespeicherte Pfad: Er wurde geändert, oder es ist ein anderes Dokument +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` muss aus "v1:" und 16 hexadezimalen Ziffern in Kleinbuchstaben bestehen Menu Item: Menübefehl: No menu item matches. Kein Menübefehl passt. Several menu items match. Choose one from the list. Mehrere Menübefehle passen. Wählen Sie einen aus der Liste. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 52a12276f7..b885996171 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6289,6 +6289,13 @@ axis Y Axis Άξονας Y @msg Batch cannot play an action with a stop Η μαζική επεξεργασία δεν μπορεί να αναπαράγει ενέργεια με διακοπή Insert Menu Item… Εισαγωγή στοιχείου μενού… Insert Menu Item Εισαγωγή στοιχείου μενού + Insert Select Path Εισαγωγή επιλογής διαδρομής + Select Path Επιλογή διαδρομής +@msg select one whole path or compound path επιλέξτε μία ολόκληρη διαδρομή ή σύνθετη διαδρομή +@msg select only one path επιλέξτε μόνο μία διαδρομή +@msg the path is hidden or locked, or cannot be selected here η διαδρομή είναι κρυφή ή κλειδωμένη, ή δεν μπορεί να επιλεγεί εδώ +@msg object {value} is not the stored path: it changed, or the document is another one το αντικείμενο {value} δεν είναι η αποθηκευμένη διαδρομή: άλλαξε ή το έγγραφο είναι άλλο +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits το `fingerprint` πρέπει να είναι "v1:" και 16 πεζά δεκαεξαδικά ψηφία Menu Item: Στοιχείο μενού: No menu item matches. Κανένα στοιχείο μενού δεν ταιριάζει. Several menu items match. Choose one from the list. Ταιριάζουν πολλά στοιχεία μενού. Επιλέξτε ένα από τη λίστα. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e6f77cd908..bc6f6f5749 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6280,6 +6280,13 @@ axis Y Axis Eje Y @msg Batch cannot play an action with a stop Lote no puede reproducir una acción con una parada Insert Menu Item… Insertar elemento de menú… Insert Menu Item Insertar elemento de menú + Insert Select Path Insertar selección de trazado + Select Path Seleccionar trazado +@msg select one whole path or compound path selecciona un trazado completo o un trazado compuesto +@msg select only one path selecciona solo un trazado +@msg the path is hidden or locked, or cannot be selected here el trazado está oculto o bloqueado, o no se puede seleccionar aquí +@msg object {value} is not the stored path: it changed, or the document is another one el objeto {value} no es el trazado guardado: ha cambiado o el documento es otro +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` debe ser "v1:" y 16 dígitos hexadecimales en minúscula Menu Item: Elemento de menú: No menu item matches. Ningún elemento de menú coincide. Several menu items match. Choose one from the list. Coinciden varios elementos de menú. Elija uno de la lista. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index dc0e432264..bf4aaa3cde 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6368,6 +6368,13 @@ axis Y Axis Y-akseli @msg Batch cannot play an action with a stop Erä ei voi toistaa toimintoa, jossa on pysäytys Insert Menu Item… Lisää valikkokohta… Insert Menu Item Lisää valikkokohta + Insert Select Path Lisää polun valinta + Select Path Valitse polku +@msg select one whole path or compound path valitse yksi kokonainen polku tai yhdistelmäpolku +@msg select only one path valitse vain yksi polku +@msg the path is hidden or locked, or cannot be selected here polku on piilotettu tai lukittu, tai sitä ei voi valita tässä +@msg object {value} is not the stored path: it changed, or the document is another one objekti {value} ei ole tallennettu polku: se on muuttunut, tai asiakirja on toinen +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` on oltava "v1:" ja 16 pientä heksadesimaalinumeroa Menu Item: Valikkokohta: No menu item matches. Mikään valikkokohta ei vastaa hakua. Several menu items match. Choose one from the list. Useampi valikkokohta vastaa hakua. Valitse yksi luettelosta. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3c74586ef0..d262b1ea5e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6333,6 +6333,13 @@ axis Y Axis Axe Y @msg Batch cannot play an action with a stop Le traitement par lots ne peut pas exécuter un script comportant un arrêt Insert Menu Item… Insérer une commande de menu… Insert Menu Item Insérer une commande de menu + Insert Select Path Insérer une sélection de tracé + Select Path Sélectionner le tracé +@msg select one whole path or compound path sélectionnez un tracé entier ou un tracé transparent +@msg select only one path sélectionnez un seul tracé +@msg the path is hidden or locked, or cannot be selected here le tracé est masqué ou verrouillé, ou ne peut pas être sélectionné ici +@msg object {value} is not the stored path: it changed, or the document is another one l’objet {value} n’est pas le tracé enregistré : il a été modifié, ou il s’agit d’un autre document +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` doit être "v1:" suivi de 16 chiffres hexadécimaux en minuscules Menu Item: Commande de menu : No menu item matches. Aucune commande de menu ne correspond. Several menu items match. Choose one from the list. Plusieurs commandes de menu correspondent. Choisissez-en une dans la liste. diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 8e1ce7cc2a..a036037049 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3465,6 +3465,13 @@ antiAlias Sharp शार्प @msg Batch cannot play an action with a stop बैच स्टॉप वाला एक्शन नहीं चला सकता Insert Menu Item… मेनू आइटम जोड़ें… Insert Menu Item मेनू आइटम जोड़ें + Insert Select Path सिलेक्ट पाथ जोड़ें + Select Path पाथ सिलेक्ट करें +@msg select one whole path or compound path एक पूरा पाथ या कंपाउंड पाथ चुनें +@msg select only one path केवल एक पाथ चुनें +@msg the path is hidden or locked, or cannot be selected here पाथ छिपा हुआ है या लॉक है, या यहाँ चुना नहीं जा सकता +@msg object {value} is not the stored path: it changed, or the document is another one ऑब्जेक्ट {value} सहेजा गया पाथ नहीं है: वह बदल गया है, या डॉक्यूमेंट दूसरा है +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` "v1:" और छोटे अक्षरों वाले 16 हेक्स अंक होना चाहिए Menu Item: मेनू आइटम: No menu item matches. कोई मेनू आइटम मेल नहीं खाता। Several menu items match. Choose one from the list. कई मेनू आइटम मेल खाते हैं। सूची से एक चुनें। diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 15ce573c11..2399e21ef1 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6321,6 +6321,13 @@ axis Y Axis Y tengely @msg Batch cannot play an action with a stop A kötegelt feldolgozás nem játszhat le megállást tartalmazó műveletet Insert Menu Item… Menüelem beszúrása… Insert Menu Item Menüelem beszúrása + Insert Select Path Útvonal kijelölésének beszúrása + Select Path Útvonal kijelölése +@msg select one whole path or compound path jelölj ki egy teljes útvonalat vagy összetett útvonalat +@msg select only one path csak egy útvonalat jelölj ki +@msg the path is hidden or locked, or cannot be selected here az útvonal rejtett vagy zárolt, vagy itt nem jelölhető ki +@msg object {value} is not the stored path: it changed, or the document is another one a(z) {value} objektum nem a tárolt útvonal: megváltozott, vagy ez egy másik dokumentum +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits a `fingerprint` értéke "v1:" és 16 kisbetűs hexadecimális számjegy kell legyen Menu Item: Menüelem: No menu item matches. Egy menüelem sem egyezik. Several menu items match. Choose one from the list. Több menüelem is egyezik. Válasszon egyet a listából. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f68fad0c1c..c93f40aa19 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5993,6 +5993,13 @@ axis Y Axis Sumbu Y @msg Batch cannot play an action with a stop Batch tidak dapat memutar tindakan dengan titik henti Insert Menu Item… Sisipkan item menu… Insert Menu Item Sisipkan item menu + Insert Select Path Sisipkan pemilihan jalur + Select Path Pilih jalur +@msg select one whole path or compound path pilih satu jalur utuh atau jalur majemuk +@msg select only one path pilih hanya satu jalur +@msg the path is hidden or locked, or cannot be selected here jalur tersembunyi atau terkunci, atau tidak dapat dipilih di sini +@msg object {value} is not the stored path: it changed, or the document is another one objek {value} bukan jalur yang disimpan: jalur itu berubah, atau dokumennya berbeda +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` harus berupa "v1:" dan 16 digit heksadesimal huruf kecil Menu Item: Item menu: No menu item matches. Tidak ada item menu yang cocok. Several menu items match. Choose one from the list. Beberapa item menu cocok. Pilih salah satu dari daftar. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 480115859b..6959c1b749 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5963,6 +5963,13 @@ axis Y Axis Asse Y @msg Batch cannot play an action with a stop L'elaborazione batch non può eseguire un'azione con un'interruzione Insert Menu Item… Inserisci voce di menu… Insert Menu Item Inserisci voce di menu + Insert Select Path Inserisci selezione tracciato + Select Path Seleziona tracciato +@msg select one whole path or compound path seleziona un tracciato intero o un tracciato composto +@msg select only one path seleziona un solo tracciato +@msg the path is hidden or locked, or cannot be selected here il tracciato è nascosto o bloccato, oppure non può essere selezionato qui +@msg object {value} is not the stored path: it changed, or the document is another one l’oggetto {value} non è il tracciato memorizzato: è cambiato, oppure il documento è un altro +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` deve essere "v1:" seguito da 16 cifre esadecimali minuscole Menu Item: Voce di menu: No menu item matches. Nessuna voce di menu corrisponde. Several menu items match. Choose one from the list. Corrispondono più voci di menu. Sceglierne una dall’elenco. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ba3105aec0..8185f116ac 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6028,6 +6028,13 @@ axis Y Axis Y 軸 @msg Batch cannot play an action with a stop バッチでは停止を含むアクションを再生できません Insert Menu Item… メニュー項目を挿入… Insert Menu Item メニュー項目を挿入 + Insert Select Path パスの選択を挿入 + Select Path パスを選択 +@msg select one whole path or compound path パス全体または複合パスを 1 つ選択してください +@msg select only one path パスを 1 つだけ選択してください +@msg the path is hidden or locked, or cannot be selected here パスが非表示またはロックされているか、ここでは選択できません +@msg object {value} is not the stored path: it changed, or the document is another one オブジェクト {value} は保存されたパスではありません: 変更されたか、別のドキュメントです +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` は "v1:" と小文字の 16 進数字 16 桁である必要があります Menu Item: メニュー項目: No menu item matches. 一致するメニュー項目がありません。 Several menu items match. Choose one from the list. 複数のメニュー項目が一致します。リストから 1 つ選択してください。 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 83fadf726a..06891d0a79 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6289,6 +6289,13 @@ axis Y Axis Y ღერძი @msg Batch cannot play an action with a stop პაკეტურ დამუშავებას არ შეუძლია გაჩერების მქონე მოქმედების შესრულება Insert Menu Item… მენიუს ელემენტის ჩასმა… Insert Menu Item მენიუს ელემენტის ჩასმა + Insert Select Path ტრაექტორიის მონიშვნის ჩასმა + Select Path ტრაექტორიის მონიშვნა +@msg select one whole path or compound path მონიშნეთ ერთი მთლიანი ტრაექტორია ან შედგენილი ტრაექტორია +@msg select only one path მონიშნეთ მხოლოდ ერთი ტრაექტორია +@msg the path is hidden or locked, or cannot be selected here ტრაექტორია დამალული ან ჩაკეტილია, ან აქ ვერ მოინიშნება +@msg object {value} is not the stored path: it changed, or the document is another one ობიექტი {value} არ არის შენახული ტრაექტორია: ის შეიცვალა, ან დოკუმენტი სხვაა +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` უნდა იყოს "v1:" და 16 პატარა თექვსმეტობითი ციფრი Menu Item: მენიუს ელემენტი: No menu item matches. მენიუს არცერთი ელემენტი არ ემთხვევა. Several menu items match. Choose one from the list. ემთხვევა მენიუს რამდენიმე ელემენტი. აირჩიეთ ერთი სიიდან. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e6f7478bf3..cce862e60f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5924,6 +5924,13 @@ axis Y Axis Y축 @msg Batch cannot play an action with a stop 일괄 처리에서는 중지가 있는 액션을 재생할 수 없습니다 Insert Menu Item… 메뉴 항목 삽입… Insert Menu Item 메뉴 항목 삽입 + Insert Select Path 패스 선택 삽입 + Select Path 패스 선택 +@msg select one whole path or compound path 전체 패스 또는 컴파운드 패스를 하나 선택하세요 +@msg select only one path 패스를 하나만 선택하세요 +@msg the path is hidden or locked, or cannot be selected here 패스가 숨겨져 있거나 잠겨 있거나 여기에서 선택할 수 없습니다 +@msg object {value} is not the stored path: it changed, or the document is another one 개체 {value}은(는) 저장된 패스가 아닙니다: 변경되었거나 다른 문서입니다 +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint`는 "v1:"과 소문자 16진수 16자리여야 합니다 Menu Item: 메뉴 항목: No menu item matches. 일치하는 메뉴 항목이 없습니다. Several menu items match. Choose one from the list. 여러 메뉴 항목이 일치합니다. 목록에서 하나를 선택하세요. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 26baaa8dc5..fdbfb2cb88 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6044,6 +6044,13 @@ axis Y Axis Y-as @msg Batch cannot play an action with a stop Batch kan geen actie met een stop afspelen Insert Menu Item… Menu-item invoegen… Insert Menu Item Menu-item invoegen + Insert Select Path Pad selecteren invoegen + Select Path Pad selecteren +@msg select one whole path or compound path selecteer één volledig pad of samengesteld pad +@msg select only one path selecteer slechts één pad +@msg the path is hidden or locked, or cannot be selected here het pad is verborgen of vergrendeld, of kan hier niet worden geselecteerd +@msg object {value} is not the stored path: it changed, or the document is another one object {value} is niet het opgeslagen pad: het is gewijzigd, of het document is een ander +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` moet "v1:" zijn, gevolgd door 16 hexadecimale cijfers in kleine letters Menu Item: Menu-item: No menu item matches. Geen menu-item komt overeen. Several menu items match. Choose one from the list. Meerdere menu-items komen overeen. Kies er een uit de lijst. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3637bb6f94..f9e5844fb9 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6192,6 +6192,13 @@ axis Y Axis Oś Y @msg Batch cannot play an action with a stop Przetwarzanie wsadowe nie może odtworzyć operacji z zatrzymaniem Insert Menu Item… Wstaw polecenie menu… Insert Menu Item Wstaw polecenie menu + Insert Select Path Wstaw zaznaczenie ścieżki + Select Path Zaznacz ścieżkę +@msg select one whole path or compound path zaznacz jedną całą ścieżkę lub ścieżkę złożoną +@msg select only one path zaznacz tylko jedną ścieżkę +@msg the path is hidden or locked, or cannot be selected here ścieżka jest ukryta lub zablokowana albo nie można jej tu zaznaczyć +@msg object {value} is not the stored path: it changed, or the document is another one obiekt {value} nie jest zapisaną ścieżką: zmienił się albo dokument jest inny +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` musi mieć postać "v1:" i 16 cyfr szesnastkowych małymi literami Menu Item: Polecenie menu: No menu item matches. Żadne polecenie menu nie pasuje. Several menu items match. Choose one from the list. Pasuje kilka poleceń menu. Wybierz jedno z listy. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 15aa70e244..168907a0c8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6253,6 +6253,13 @@ axis Y Axis Eixo Y @msg Batch cannot play an action with a stop Lote não pode reproduzir uma ação com uma parada Insert Menu Item… Inserir Item de Menu… Insert Menu Item Inserir Item de Menu + Insert Select Path Inserir Seleção de Caminho + Select Path Selecionar Caminho +@msg select one whole path or compound path selecione um caminho inteiro ou um caminho composto +@msg select only one path selecione apenas um caminho +@msg the path is hidden or locked, or cannot be selected here o caminho está oculto ou bloqueado, ou não pode ser selecionado aqui +@msg object {value} is not the stored path: it changed, or the document is another one o objeto {value} não é o caminho armazenado: ele mudou, ou o documento é outro +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` deve ser "v1:" e 16 dígitos hexadecimais minúsculos Menu Item: Item de menu: No menu item matches. Nenhum item de menu corresponde. Several menu items match. Choose one from the list. Vários itens de menu correspondem. Escolha um da lista. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 21f270a0fb..379598b34c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5949,6 +5949,13 @@ axis Y Axis Ось Y @msg Batch cannot play an action with a stop Пакет не может воспроизвести действие с остановкой Insert Menu Item… Вставить пункт меню… Insert Menu Item Вставить пункт меню + Insert Select Path Вставить выделение контура + Select Path Выделить контур +@msg select one whole path or compound path выделите один целый контур или составной контур +@msg select only one path выделите только один контур +@msg the path is hidden or locked, or cannot be selected here контур скрыт или заблокирован либо не может быть выделен здесь +@msg object {value} is not the stored path: it changed, or the document is another one объект {value} не является сохранённым контуром: он изменился или это другой документ +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` должен состоять из "v1:" и 16 шестнадцатеричных цифр в нижнем регистре Menu Item: Пункт меню: No menu item matches. Нет подходящих пунктов меню. Several menu items match. Choose one from the list. Подходит несколько пунктов меню. Выберите один из списка. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 7969960880..47e8ed6640 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6319,6 +6319,13 @@ axis Y Axis Y-axel @msg Batch cannot play an action with a stop Batch kan inte spela upp en åtgärd med ett stopp Insert Menu Item… Infoga menyalternativ… Insert Menu Item Infoga menyalternativ + Insert Select Path Infoga markering av bana + Select Path Markera bana +@msg select one whole path or compound path markera en hel bana eller sammansatt bana +@msg select only one path markera bara en bana +@msg the path is hidden or locked, or cannot be selected here banan är dold eller låst, eller kan inte markeras här +@msg object {value} is not the stored path: it changed, or the document is another one objekt {value} är inte den sparade banan: den har ändrats, eller så är dokumentet ett annat +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` måste vara "v1:" och 16 hexadecimala siffror med gemener Menu Item: Menyalternativ: No menu item matches. Inget menyalternativ matchar. Several menu items match. Choose one from the list. Flera menyalternativ matchar. Välj ett i listan. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 639bf692a8..9e67b91ff4 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6294,6 +6294,13 @@ axis Y Axis Y ekseni @msg Batch cannot play an action with a stop Toplu işlem durak içeren bir eylemi oynatamaz Insert Menu Item… Menü Öğesi Ekle… Insert Menu Item Menü Öğesi Ekle + Insert Select Path Yol Seçimi Ekle + Select Path Yolu Seç +@msg select one whole path or compound path bir yolun tamamını veya bir bileşik yolu seçin +@msg select only one path yalnızca bir yol seçin +@msg the path is hidden or locked, or cannot be selected here yol gizli veya kilitli ya da burada seçilemiyor +@msg object {value} is not the stored path: it changed, or the document is another one {value} nesnesi kaydedilen yol değil: değişti ya da belge başka +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` "v1:" ve 16 küçük harfli onaltılık basamak olmalıdır Menu Item: Menü öğesi: No menu item matches. Eşleşen menü öğesi yok. Several menu items match. Choose one from the list. Birden çok menü öğesi eşleşiyor. Listeden birini seçin. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 2889757941..4eec319785 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5943,6 +5943,13 @@ axis Y Axis Вісь Y @msg Batch cannot play an action with a stop Пакет не може відтворити дію із зупинкою Insert Menu Item… Вставити пункт меню… Insert Menu Item Вставити пункт меню + Insert Select Path Вставити виділення контуру + Select Path Виділити контур +@msg select one whole path or compound path виділіть один цілий контур або складений контур +@msg select only one path виділіть лише один контур +@msg the path is hidden or locked, or cannot be selected here контур прихований або заблокований, або його не можна виділити тут +@msg object {value} is not the stored path: it changed, or the document is another one об'єкт {value} не є збереженим контуром: його змінено або це інший документ +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` має складатися з "v1:" і 16 шістнадцяткових цифр у нижньому регістрі Menu Item: Пункт меню: No menu item matches. Немає відповідних пунктів меню. Several menu items match. Choose one from the list. Відповідає кілька пунктів меню. Виберіть один зі списку. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ca39a2cd16..6b72631716 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5944,6 +5944,13 @@ axis Y Axis Trục Y @msg Batch cannot play an action with a stop Hàng loạt không thể phát hành động có điểm dừng Insert Menu Item… Chèn mục menu… Insert Menu Item Chèn mục menu + Insert Select Path Chèn chọn đường dẫn + Select Path Chọn đường dẫn +@msg select one whole path or compound path hãy chọn một đường dẫn trọn vẹn hoặc đường dẫn phức +@msg select only one path chỉ chọn một đường dẫn +@msg the path is hidden or locked, or cannot be selected here đường dẫn bị ẩn hoặc bị khóa, hoặc không thể chọn ở đây +@msg object {value} is not the stored path: it changed, or the document is another one đối tượng {value} không phải là đường dẫn đã lưu: nó đã thay đổi, hoặc đây là tài liệu khác +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` phải là "v1:" và 16 chữ số thập lục phân viết thường Menu Item: Mục menu: No menu item matches. Không có mục menu nào khớp. Several menu items match. Choose one from the list. Có nhiều mục menu khớp. Hãy chọn một mục trong danh sách. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d4fe4ce4f3..6ab859d646 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6176,6 +6176,13 @@ axis Y Axis Y 轴 @msg Batch cannot play an action with a stop 批处理无法播放含有停止的动作 Insert Menu Item… 插入菜单项… Insert Menu Item 插入菜单项 + Insert Select Path 插入选择路径 + Select Path 选择路径 +@msg select one whole path or compound path 请选择一条完整路径或复合路径 +@msg select only one path 请只选择一条路径 +@msg the path is hidden or locked, or cannot be selected here 该路径已隐藏或锁定,或无法在此处选择 +@msg object {value} is not the stored path: it changed, or the document is another one 对象 {value} 不是存储的路径:它已更改,或这是另一个文档 +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` 必须为 "v1:" 加 16 位小写十六进制数字 Menu Item: 菜单项: No menu item matches. 没有匹配的菜单项。 Several menu items match. Choose one from the list. 有多个菜单项匹配。请从列表中选择一个。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1a81d6d01c..6f86b74438 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5981,6 +5981,13 @@ axis Y Axis Y 軸 @msg Batch cannot play an action with a stop 批次無法播放含有停止的動作 Insert Menu Item… 插入選單項目… Insert Menu Item 插入選單項目 + Insert Select Path 插入選取路徑 + Select Path 選取路徑 +@msg select one whole path or compound path 請選取一條完整路徑或複合路徑 +@msg select only one path 請只選取一條路徑 +@msg the path is hidden or locked, or cannot be selected here 該路徑已隱藏或鎖定,或無法在此處選取 +@msg object {value} is not the stored path: it changed, or the document is another one 物件 {value} 不是儲存的路徑:它已變更,或這是另一份文件 +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` 必須為 "v1:" 加上 16 位小寫十六進位數字 Menu Item: 選單項目: No menu item matches. 沒有相符的選單項目。 Several menu items match. Choose one from the list. 有多個選單項目相符。請從清單中選擇一個。 diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 72e6c25acb..dfb88c880c 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -978,6 +978,7 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::actions::stop(app, p), crate::panels::actions::INSERT_STOP => crate::panels::actions::insert_stop(app, p), crate::panels::actions::INSERT_MENU_ITEM => crate::panels::actions::insert_menu_item(app, p), + crate::panels::actions::INSERT_SELECT_PATH => crate::panels::actions::insert_select_path(app, p), crate::panels::actions::CONTINUE_PLAYBACK => crate::panels::actions::continue_playback(app, p), crate::panels::actions::DELETE_STEP => crate::panels::actions::delete_step(app, p), "ui.batchDialog" => { diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index b0b2db020d..e2a07ee680 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1434,6 +1434,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{set?: index, action?: index, step?: index, command, params?: object} insert the command of a menu item as a step, without running it; the step plays as choosing the item runs it. command and params (default {}) must be those of a menu item that can be a step: one that runs an engine command, whose label does not end with \"…\", outside the VectorCraft, File, Window and Help menus, and that is not Undo, Redo, a Paste, a batch (command.batch), a preference (prefs.*) or action (action.*) command, an Object › Align item, Image Trace's Make or Make and Expand, Pattern's Make or Edit Pattern, a saved selection, saved view or recent font item, or a plug-in's item. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. A command and params that no such menu item has, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Menu Item… (dialog `actionInsertMenuItem` with find, command, params, set, action and step) runs this on OK → {set, action, step}, or {recording: true}", ), + ( + crate::panels::actions::INSERT_SELECT_PATH, + "Insert Select Path", + "", + "{set?: index, action?: index, step?: index} insert a Select Path step for the path selected in the active document, without changing the selection: the step `select.storedPath {id, fingerprint}` stores the path's id and a fingerprint of its kind, fill rule and anchors, and playback selects only that path while it is still there unchanged and can be selected, else the step is an error. The selection must be one whole path or compound path that can be selected: not anchors or segments alone, several objects, a group, type or another kind of object, a guide, a member of a compound path, or a hidden or locked object. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. No document, a selection that is not such a path, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Select Path runs this, for the selected step or action or the recording in progress → {set, action, step}, or {recording: true}", + ), ( crate::panels::actions::CONTINUE_PLAYBACK, "Continue Action", diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index f2426f33ab..1ac205c2c7 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -948,6 +948,31 @@ pub fn insert_menu_item(app: &mut VectorcraftApp, p: &Value) -> Result Result { + if !p.is_object() { + return Err("`params` must be an object".into()); + } + let st = app.session.active().ok_or("no document open")?; + let params = vectorcraft_engine::stored_path_step(st)?; + insert(app, p, (SELECT_PATH_STEP.to_string(), params)) +} + /// The journal entry the recording in progress records from. fn recording_start(app: &VectorcraftApp) -> Option { let (_, _, start) = app.ui.recording.as_ref()?; @@ -1706,6 +1731,17 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let step = selected_step(app, ui.ctx(), selected); crate::dialogs::action_insert_menu_item::open(app, if recording { None } else { selected }, step); } + let path = app.session.active().is_some_and(|st| vectorcraft_engine::storable_selection(st).is_ok()); + if widgets::menu_item(ui, tl!("Insert Select Path"), (recording || has) && path, false) { + let place = match (recording, selected, selected_step(app, ui.ctx(), selected)) { + (true, ..) | (false, None, _) => json!({}), + (false, Some((s, a)), None) => json!({"set": s, "action": a}), + (false, Some((s, a)), Some(step)) => json!({"set": s, "action": a, "step": step}), + }; + if let Err(e) = app.run(INSERT_SELECT_PATH, place) { + app.status(e); + } + } if widgets::menu_item(ui, tl!("Action Options…"), has, false) && let Some((s, a)) = selected { @@ -1892,10 +1928,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { current = Some(si); sets_selected = vec![si]; } - // A stop's row shows its message; other steps show their command. + // A stop's row shows its message, and a Select Path step's its name; other + // steps show their command. let shown = if command == STOP_STEP { let message = params.get("message").and_then(Value::as_str).and_then(|m| m.lines().next()).unwrap_or_default(); crate::i18n::fmt(tl!("Stop: {message}"), &[("message", message)]) + } else if command == SELECT_PATH_STEP { + tl!("Select Path").to_string() } else { command.clone() }; @@ -3087,7 +3126,7 @@ mod tests { (json!(long.to_string()), "an action in the action set file has more than 10,000 steps"), (json!(" ".repeat(files::MAX_SET_FILE + 1)), "the action set file is larger than 8 MiB"), ( - json!("{\"format\": \"vcactions\", \"version\": 4, \"name\": \"S\", \"actions\": []}"), + json!("{\"format\": \"vcactions\", \"version\": 5, \"name\": \"S\", \"actions\": []}"), "the action set file needs a later version of VectorCraft", ), ] { @@ -4474,6 +4513,175 @@ mod tests { assert_eq!(recorded.steps, [("object.group".to_string(), json!({}))]); } + /// [`app_with_three_moves`] with a second rectangle, 100 pt to the right, and the first one + /// selected again → (the app, the left edge of the first one as it moves, the ids of both). + fn app_with_two_rectangles() -> (VectorcraftApp, impl Fn(&VectorcraftApp) -> f64, [u64; 2]) { + let (mut app, x) = app_with_three_moves(); + let first = app.session.active().unwrap().selection.objects[0].0; + let second = app.run("shape.rectangle", json!({"x": 110, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [first]})).unwrap(); + (app, x, [first, second]) + } + + fn left_of(app: &VectorcraftApp, id: u64) -> f64 { + app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().x0 + } + + #[test] + fn insert_select_path_stores_the_selected_path_changes_nothing_and_plays_by_selecting_it() { + let (mut app, x, [first, second]) = app_with_two_rectangles(); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([0, 2]); + let doc = app.session.active().unwrap().doc.clone(); + let journal = app.session.journal.len(); + let undo = undo_steps(&app); + let r = app.run(INSERT_SELECT_PATH, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + assert_eq!(r, json!({"set": 0, "action": 0, "step": 1})); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc), "inserting edits nothing"); + assert_eq!(app.session.journal.len(), journal, "and records nothing"); + assert_eq!(undo_steps(&app), undo, "and adds no undo step"); + let action = &app.ui.action_sets[0].actions[0]; + assert_eq!(action.steps[1].0, SELECT_PATH_STEP); + assert_eq!(action.steps[1].1["id"], json!(first)); + assert!(action.steps[1].1["fingerprint"].as_str().is_some_and(|f| f.starts_with("v1:")), "{:?}", action.steps[1]); + assert_eq!(action.excluded_steps, BTreeSet::from([0, 3]), "the step excluded after the insertion point moved with it"); + assert!(!action.excluded_steps.contains(&1), "the new step is included"); + + app.run("select.set", json!({"ids": [second]})).unwrap(); + let before = undo_steps(&app); + play(&mut app, 0, 0).unwrap(); + assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(first)], "the step selected the stored path"); + assert_eq!(x(&app), 2.0, "the move after it moved the stored path"); + assert_eq!(left_of(&app, second), 110.0, "and not the path selected before"); + assert_eq!(undo_steps(&app), before + 1, "the action plays as one undo step"); + } + + #[test] + fn a_select_path_step_plays_step_by_step_and_a_changed_path_ends_the_playback() { + let (mut app, x, [_, second]) = app_with_two_rectangles(); + app.ui.action_sets[0].actions[0].steps.truncate(1); + app.run(INSERT_SELECT_PATH, json!({"set": 0, "action": 0})).unwrap(); + app.ui.action_sets[0].actions[0].steps.push(("object.move".into(), json!({"dx": 4, "dy": 0}))); + app.run(PLAYBACK_OPTIONS, json!({"speed": "step"})).unwrap(); + let before = undo_steps(&app); + app.run("select.set", json!({"ids": [second]})).unwrap(); + assert_eq!(undo_steps(&app), before, "a selection change alone is no undo step"); + play(&mut app, 0, 0).unwrap(); + play_out(&mut app); + assert_eq!(left_of(&app, second), 111.0, "the first move moved the path selected when the action started"); + assert_eq!(x(&app), 4.0, "the step selected the stored path, and the last move moved it"); + assert_eq!(undo_steps(&app), before + 2, "each move its own undo step, and the selection change none, as select.set adds none"); + + // The stored path has moved since: the step ends the playback, and the move after it does not run. + app.run("select.set", json!({"ids": [second]})).unwrap(); + play(&mut app, 0, 0).unwrap(); + play_out(&mut app); + assert!(app.ui.status.contains("is not the stored path"), "{}", app.ui.status); + assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(second)], "the selection stays as it was"); + assert_eq!((left_of(&app, second), x(&app)), (112.0, 4.0), "the step before it ran, the one after it did not"); + } + + #[test] + fn insert_select_path_refuses_what_is_not_one_whole_path_and_changes_nothing() { + let (mut app, _, [first, second]) = app_with_two_rectangles(); + let place = json!({"set": 0, "action": 0}); + let refused = |app: &mut VectorcraftApp, params: Value, want: &str| { + let selection = app.session.active().map(|st| st.selection.objects.clone()); + assert_eq!(app.run(INSERT_SELECT_PATH, params.clone()).unwrap_err(), want, "{params}"); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "{params}"); + assert_eq!(app.session.active().map(|st| st.selection.objects.clone()), selection, "{params}"); + }; + app.run("select.set", json!({"ids": [first, second]})).unwrap(); + refused(&mut app, place.clone(), "select only one path"); + app.run("select.none", json!({})).unwrap(); + refused(&mut app, place.clone(), "select a path"); + app.run("select.anchors", json!({"id": first, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + refused(&mut app, place.clone(), "select one whole path or compound path"); + app.run("select.set", json!({"ids": [first, second]})).unwrap(); + let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [group]})).unwrap(); + refused(&mut app, place.clone(), "select one whole path or compound path"); + app.run("select.set", json!({"ids": [first]})).unwrap(); + for (params, want) in [ + (json!({}), "no such action"), + (json!({"set": 0}), "no such action"), + (json!({"set": 1, "action": 0}), "no such action"), + (json!({"set": 0, "action": 1}), "no such action"), + (json!({"set": "0", "action": 0}), "no such action"), + (json!({"set": 0, "action": 0, "step": 3}), "no such action step"), + (json!({"set": 0, "action": 0, "step": null}), "no such action step"), + (json!(5), "`params` must be an object"), + (json!(null), "`params` must be an object"), + ] { + refused(&mut app, params, want); + } + let mut empty = app_with_sets(); + assert_eq!(empty.run(INSERT_SELECT_PATH, json!({"set": 0, "action": 0})).unwrap_err(), "no document open"); + } + + #[test] + fn insert_select_path_goes_into_the_recording() { + let (mut app, _, [first, _]) = app_with_two_rectangles(); + app.run(START_RECORDING, json!({})).unwrap(); + assert_eq!(app.run(INSERT_SELECT_PATH, json!({})).unwrap(), json!({"recording": true})); + assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(first)]); + app.run(STOP_RECORDING, json!({})).unwrap(); + let recorded = app.ui.action_sets[0].actions.last().unwrap(); + assert_eq!(recorded.steps.len(), 1); + assert_eq!((recorded.steps[0].0.as_str(), &recorded.steps[0].1["id"]), (SELECT_PATH_STEP, &json!(first))); + } + + #[test] + fn a_saved_select_path_step_survives_and_a_damaged_one_is_refused() { + let (mut app, _, _) = app_with_two_rectangles(); + app.run(INSERT_SELECT_PATH, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + let saved = files::write_set(&app.ui.action_sets[0]); + assert_eq!(serde_json::from_str::(&saved).unwrap()["version"], json!(4), "older builds must refuse a Select Path step"); + let loaded = files::read_set(&saved, "Test").unwrap(); + assert_eq!(loaded.actions[0].steps, app.ui.action_sets[0].actions[0].steps); + let step = app.ui.action_sets[0].actions[0].steps[1].clone(); + let nested = json!({"commands": [{"command": SELECT_PATH_STEP, "params": step.1}]}); + let set = ActionSet::new("Nested", vec![Action::new("A", vec![("command.batch".into(), nested)])]); + assert_eq!(serde_json::from_str::(&files::write_set(&set)).unwrap()["version"], json!(4), "a step in a recorded batch counts"); + for params in + [json!({"id": 3}), json!({"id": -1, "fingerprint": step.1["fingerprint"]}), json!({"id": 3, "fingerprint": "v1:xyz"}), json!(42)] + { + let bad = json!({"command": SELECT_PATH_STEP, "params": params}); + for s in [bad.clone(), json!({"command": "command.batch", "params": {"commands": [bad]}})] { + let file = json!({"format": "vcactions", "version": 4, "name": "Bad", "actions": [{"name": "A", "steps": [s]}]}); + assert_eq!(files::read_set(&file.to_string(), "Test").unwrap_err(), "not an action set file", "{s}"); + } + } + } + + #[test] + fn insert_select_path_in_the_menu_needs_a_path_and_shows_its_row() { + use super::super::tests_appearance::{click, frame_events, text_rect}; + let (mut app, _, [first, _]) = app_with_two_rectangles(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + let menu_click = |ctx: &egui::Context, app: &mut VectorcraftApp| { + let texts = frame_events(ctx, app, vec![], menu); + let item = texts.iter().find(|(s, _)| s.trim() == "Insert Select Path").map(|(_, r)| *r).expect("Insert Select Path in the menu"); + click(ctx, app, item.center(), menu); + }; + menu_click(&ctx, &mut app); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "no action selected and nothing recording: the item is off"); + + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "Three Moves").center(), show); + app.run("select.none", json!({})).unwrap(); + menu_click(&ctx, &mut app); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "no path selected: the item is off"); + + app.run("select.set", json!({"ids": [first]})).unwrap(); + menu_click(&ctx, &mut app); + let steps = &app.ui.action_sets[0].actions[0].steps; + assert_eq!((steps.len(), steps[3].0.as_str()), (4, SELECT_PATH_STEP), "the action selected: the step goes at its end"); + let texts = frame_events(&ctx, &mut app, vec![], show); + assert!(texts.iter().any(|(s, _)| s == "Select Path"), "the step's row names it: {texts:?}"); + } + /// An action stays one that Load Actions… reads back: a stop or a menu item that would give it /// more than 10,000 steps, or its set's file more than 8 MiB, is refused, and the action keeps its /// steps and exclusions as they were. @@ -5520,7 +5728,7 @@ mod tests { json!({"format": "vcactions", "version": version, "name": "Old", "actions": [{"name": "A", "steps": [{"command": "object.move"}]}]}); assert_eq!(files::read_set(&old.to_string(), "Test").unwrap().actions[0].steps.len(), 1, "version {version}"); } - let later = json!({"format": "vcactions", "version": 4, "name": "New", "actions": []}); + let later = json!({"format": "vcactions", "version": 5, "name": "New", "actions": []}); assert_eq!(files::read_set(&later.to_string(), "Test").unwrap_err(), "the action set file needs a later version of VectorCraft"); for params in [json!({"message": 5}), json!({"allowContinue": "yes"}), json!(42)] { let stop = json!({"command": "action.stop", "params": params}); diff --git a/crates/ui-egui/src/panels/actions/files.rs b/crates/ui-egui/src/panels/actions/files.rs index fd8b40fbd7..b48c5e114c 100644 --- a/crates/ui-egui/src/panels/actions/files.rs +++ b/crates/ui-egui/src/panels/actions/files.rs @@ -1,14 +1,16 @@ //! Action set files: the Actions panel menu's Save Actions… and Load Actions…. //! -//! An action set file (`.vcactions`) is JSON: `{"format": "vcactions", "version": 1, 2 or 3, "name", +//! An action set file (`.vcactions`) is JSON: `{"format": "vcactions", "version": 1, 2, 3 or 4, "name", //! "actions": [{"name", "steps": [{"command", "params", "excluded"?}], "key"?, "color"?}]}`. Each step is a //! command id and its params, as `command.batch` takes them, or a stop: the command `action.stop` -//! with `{"message", "allowContinue"}`. `key` is the action's function key +//! with `{"message", "allowContinue"}`. A Select Path step is the command `select.storedPath` with +//! `{"id", "fingerprint"}`. `key` is the action's function key //! (`{"key": 1 to 15, "cmd", "shift"}`) and `color` its Button Mode color; an action without them, //! or with a value that is not one, has none. `excluded: true` keeps a step but skips it during //! playback. Sets with exclusions use version 2 so older builds refuse them rather than replay -//! excluded steps, and sets with stops use version 3 so older builds refuse them rather than fail -//! at a stop. Other sets use version 1, and earlier version 1 and 2 files still work. +//! excluded steps, sets with stops use version 3 so older builds refuse them rather than fail at a +//! stop, and sets with Select Path steps use version 4 so older builds refuse them rather than fail +//! at the step. Other sets use version 1, and earlier version 1, 2 and 3 files still work. //! Load Actions… also reads the action sets another app saves as text (`.aia`, through //! `actionSet.read`). @@ -132,6 +134,20 @@ fn stops_in<'a>(command: &str, params: &'a Value) -> Vec<&'a Value> { steps.filter(|step| super::is_stop(step)).map(|step| step.get("params").unwrap_or(&Value::Null)).collect() } +/// The params of the Select Path steps in a step: a Select Path step's own, or those of the Select +/// Path steps a `command.batch` step holds. +fn select_paths_in<'a>(command: &str, params: &'a Value) -> Vec<&'a Value> { + if command == super::SELECT_PATH_STEP { + return vec![params]; + } + if command != "command.batch" { + return vec![]; + } + let steps = params.get("commands").and_then(Value::as_array).into_iter().flatten(); + let select_path = |step: &&Value| step.get("command").and_then(Value::as_str) == Some(super::SELECT_PATH_STEP); + steps.filter(select_path).map(|step| step.get("params").unwrap_or(&Value::Null)).collect() +} + /// `set` as the text of an action set file. pub fn write_set(set: &ActionSet) -> String { let actions = set @@ -150,10 +166,12 @@ pub fn write_set(set: &ActionSet) -> String { }) .collect(); let has_stop = set.actions.iter().flat_map(|a| &a.steps).any(|(command, params)| !stops_in(command, params).is_empty()); - let version = match (has_stop, set.actions.iter().any(|a| !a.excluded_steps.is_empty())) { - (true, _) => 3, - (false, true) => 2, - (false, false) => 1, + let has_select_path = set.actions.iter().flat_map(|a| &a.steps).any(|(command, params)| !select_paths_in(command, params).is_empty()); + let version = match (has_select_path, has_stop, set.actions.iter().any(|a| !a.excluded_steps.is_empty())) { + (true, ..) => 4, + (false, true, _) => 3, + (false, false, true) => 2, + (false, false, false) => 1, }; let file = SetFile { format: ACTIONS_EXT.into(), version, name: set.name.clone(), actions }; serde_json::to_string_pretty(&file).unwrap_or_default() @@ -168,7 +186,7 @@ pub fn read_set(text: &str, name: &str) -> Result { if f.format != ACTIONS_EXT { return Err("not an action set file".into()); } - if !matches!(f.version, 1..=3) { + if !matches!(f.version, 1..=4) { return Err("the action set file needs a later version of VectorCraft".into()); } if f.actions.len() > MAX_SET_ACTIONS { @@ -181,6 +199,10 @@ pub fn read_set(text: &str, name: &str) -> Result { if stops.any(|p| super::stop_params(p).is_err()) { return Err("not an action set file".into()); } + let mut select_paths = f.actions.iter().flat_map(|a| &a.steps).flat_map(|s| select_paths_in(&s.command, &s.params)); + if select_paths.any(|p| vectorcraft_engine::stored_path_params(p).is_err()) { + return Err("not an action set file".into()); + } let actions = f .actions .into_iter() diff --git a/docs/mcp.md b/docs/mcp.md index 0a6d0cf4b9..81197ca955 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3070,6 +3070,26 @@ already walks Image Trace. Short agent entry points: `actionInsertMenuItem` dialog (`find`, `command`, `params`, `set`, `action`, `step`): typing in Find and pressing Find picks the one item whose menu path, shown name or command id holds every word typed, or lists the matches, and OK runs this. +- Actions: `ui.action.insertSelectPath {set?, action?, step?}` inserts a Select Path step for the path selected in the + active document, without changing the selection or the art. The step is `select.storedPath {id, fingerprint}`: the + path's id and a fingerprint of its kind, fill rule and anchors (`v1:` and 16 lowercase hex digits, FNV-1a over them). + The selection must be one whole path or compound path that can be selected. Anchors or segments of a path alone, + several objects, a path with selected ruler guides, a group, type or any other object, a guide, a member of a compound + path, and a hidden or locked object are refused. A path inside a group can be stored on its own. The place, the selected step and the result are + as for `ui.action.insertStop`. No document, a selection that is not such a path, params that are not an object, an + index past the last set, action or step, no action and no recording, or a step past the 10,000-step and 8 MiB limits + is an error, and then nothing changes. The panel menu's Insert Select Path runs this for the selected step or action, + or the recording in progress, and is off while the selection is not such a path. The step's row in the panel reads + Select Path. An action set file holding the step is version 4, and Load Actions… refuses a step whose params are not + an id and such a fingerprint. +- Actions: `select.storedPath {id, fingerprint}` selects only the path `id` while its fingerprint is still + `fingerprint` and it can be selected: not hidden or locked, nor in a hidden or locked layer or group, a template + layer or on a locked artboard, and inside the isolated object in isolation mode. A missing object, another kind of + object, a path whose kind, fill rule or anchors changed (moving it changes its anchors), or one that cannot be selected + is an error, and the selection stays as it was. So a Select Path step finds its path in the document it was stored + from, and in another document only an object with the same id and the same fingerprint. A selection change alone adds + no undo step. In an Accelerated playback the error undoes the steps before it, and at Step by Step and Pause For it + ends the playback after them. Returns `{count, ids}`. These rules are VectorCraft's own. - Actions: `ui.action.continue {}` plays on from the stop the playing action waits at, on the document active now, and closes the stop's message box (`actionStopMessage`). The next step runs at once, or at Accelerated the steps up to the next stop as one undo step. It returns `{results, pending, atStop?}`. With no action waiting at a stop it is an diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 68b93ac4d8..6850ad0ba9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -741,6 +741,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel: the panel menu's Playback Options… sets how actions play: Accelerated (the default) plays every step at once, Step by Step plays one step at a time with the canvas drawn after each, and Pause For waits the given number of seconds between steps ([Specify playback speed](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/specify-playback-speed.html); `ui.action.playbackOptions`). VectorCraft's own rules, which the page does not cover: the pause is a whole number of seconds from 1 to 60, the setting is kept with the UI preferences, a paced playback ends at Stop Playing/Recording or when a step comes due while another document is active, each step of a paced playback is its own undo step, and Batch… plays at full speed. - Actions panel: the panel menu's Insert Stop… opens Record Stop, with a Message box and Allow Continue, and adds a stop to the recording in progress, after the selected command, or at the end of the selected action ([Insert stops in actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-stops-in-actions.html); `ui.action.insertStop`). A playing action waits at the stop and shows the message so the user can do a manual task, and Play Current Selection plays the rest ([Illustrator CS5 help](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=459); `ui.action.continue`). With Allow Continue, the message box also has Continue. VectorCraft's own rules, which the pages do not cover: an empty message still waits, Stop Playing/Recording ends the playback and keeps the steps already played, the rest plays on the document active then, a stop waits at every playback speed, at Accelerated the steps between two stops are one undo step, Batch… refuses an action with an included stop, and Delete Selection on a selected step deletes that step, a stop or a command, without asking (`ui.action.deleteStep`). - Actions panel: the panel menu's Insert Menu Item… adds a menu command to the recording in progress, after the selected step, or at the end of the selected action, without running it, and playback runs it as the menu does ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); `ui.action.insertMenuItem`). The dialog's Find box picks the one item that matches the words typed, and a click on a listed item picks it. VectorCraft's own rules, which the page does not cover: items that open a dialog, the Object › Align items, Image Trace's Make and Make and Expand, Pattern's Make and Edit Pattern, and the File, Window and Help menu items are not listed, and an action holds at most 10,000 steps and its set file at most 8 MiB, for stops too. + - Actions panel: with a path selected, the panel menu's Insert Select Path adds a step that selects that path to the recording in progress, after the selected step, or at the end of the selected action, without changing the selection ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); `ui.action.insertSelectPath`, `select.storedPath`). VectorCraft's own rules, which the page does not cover: the selection must be one whole path or compound path that can be selected, the step stores the path's id and a fingerprint of its anchors, and playback selects the path only while it is still there unchanged and can be selected, else the step is an error and the selection stays; the row reads Select Path, and a set file with the step is version 4. - Actions panel: Delete Selection deletes the selected action set with its actions and keeps the other sets ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.delete`). VectorCraft's own rules, which the overview does not cover: deleting a set asks first, Shift-click on a set's name selects a range of sets to delete together, the panel menu's Delete does the same, and Default Actions and the last set can be deleted. Undo does not bring a deleted set back. - Actions panel: the panel menu's Duplicate copies the selected action, else the current action set with all its actions ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.duplicate`). VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. From bb50d438c85fd3e32a7771cca1e543118701c55e Mon Sep 17 00:00:00 2001 From: jmverges Date: Thu, 12 Nov 2026 21:23:26 +0000 Subject: [PATCH 04/17] .ai import: type from the text document takes the face of its exact PostScript name and recorded version (#6983) Type read from a text document's font record was set in whichever installed face first answered to the record's PostScript name (ignoring case and punctuation), and its family was then swapped for any available family spelled alike but for case and punctuation (`FontDb::find_family`): a record naming `SampleInk`, the PostScript name of `Sample/Ink`, set its type in `SAMPLE INK`. Copies of one family and style under other PostScript names or versions were not told apart either, so the type could set at other widths than the file's own page. - `named_font` / `document_font` (crates/eps/src/import/text.rs) give the family and style of the installed face of exactly that PostScript name, else one that differs only in case or punctuation, and name its version when the family and style alone resolve to another installed copy. - The font record's key 5, its version string (name ID 5), is read and picks that version among the faces of the name. Each record is resolved once. - `FontDb::postscript_faces` lists every installed face a PostScript name answers to, loading their families; the import prefers among them one of exactly that name. - Type a PostScript program sets (EPS import) and legacy .ai type take the face of their font's name the same way. - The .ai save writes a static font's version (`/5`) when its PostScript name alone would give the reader another installed copy (another version, or the same name in another family), keeps the version in the style it compares, and checks that the type comes back in the same family, style and face, taking the record's version as the reader does (up to 256 bytes), so such type round-trips without a note. Measured on 224 users' files (used locally, never committed): 16 files set some type in another face; 12 render differently against their own page, 10 closer and 2 further by 0.03% of the page; envelopes of type that open live go from 154 to 159, none fewer in any file; no file opens another way. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/ai_editing/tests.rs | 122 +++++++++++++++++++++- crates/eps/src/ai_editing/writer/texts.rs | 50 +++++---- crates/eps/src/import.rs | 3 +- crates/eps/src/import/ai/legacy_text.rs | 10 +- crates/eps/src/import/ai/tests.rs | 22 ++++ crates/eps/src/import/ate.rs | 65 ++++++++++-- crates/eps/src/import/tests_fontnames.rs | 34 ++++++ crates/eps/src/import/tests_native.rs | 67 ++++++++++++ crates/eps/src/import/text.rs | 79 +++++++++++--- crates/text/src/fontdb/lookup.rs | 30 ++++++ crates/text/src/test_fonts.rs | 24 +++++ crates/text/src/tests/versions.rs | 23 ++++ docs/file-format-parity.md | 7 +- 13 files changed, 487 insertions(+), 49 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 43d93cec25..b7270f614e 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -4423,7 +4423,7 @@ fn type_attributes_the_data_cant_hold_are_reported() { let back = read_back(&doc); assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Text(_))); // Any other attribute the text document doesn't hold is reported too: No Break, a paragraph's - // left-to-right direction set on it, Optical Margin Alignment, a font version, a font that isn't + // left-to-right direction set on it, Optical Margin Alignment, a font that isn't // installed whose name doesn't come back (a hyphen in its family), a Lab color, // small capitals at another size, right-to-left letters aligned to the start of a paragraph // without a direction of its own, tracking that isn't a whole number. @@ -4432,7 +4432,6 @@ fn type_attributes_the_data_cant_hold_are_reported() { |t| t.runs[0].style.tracking = 12.5, |t| t.para.direction = Some(vectorcraft_doc::ParaDirection::LeftToRight), |t| t.optical_margin = true, - |t| t.runs[0].style.font_version = Some("2.0".into()), |t| t.runs[0].style.font_family = "my-missing font".into(), |t| t.runs[0].style.fill = Paint::solid(MINT), |t| t.runs[0].style.small_caps = Some(80.0), @@ -4455,11 +4454,130 @@ fn type_attributes_the_data_cant_hold_are_reported() { missing.runs[0].style.font_family = "Missing Sans Probe".into(); missing.runs[0].style.font_style = "Bold Italic".into(); assert!(with(missing).is_empty()); + // A version that isn't installed draws in the face the font resolves to, and comes back in it. + let mut uninstalled = t.clone(); + uninstalled.runs[0].style.font_version = Some("2.0".into()); + assert!(with(uninstalled).is_empty()); edit(&mut t); assert_eq!(with(t), [writer::TYPE_ATTRIBUTES]); } } +/// Installs the copies of each font `copies` lists, the first of each loaded first. +fn install_copies(copies: &[(bool, vectorcraft_text::test_fonts::CopyNames<'_>)]) { + for (inter, names) in copies { + let font = vectorcraft_text::test_fonts::named_copy(*inter, *names).unwrap(); + assert_eq!(vectorcraft_text::FontDb::global().add_font(font), 1, "{names:?}"); + } +} + +/// A run's text, font family and font version, as [`fonts_saved`] reads them back. +type SavedRun = (String, String, Option); + +/// A 200 × 100 document of point type whose runs are `runs` (text, family, version). +fn runs_doc(runs: &[(&str, &str, Option<&str>)]) -> Document { + use vectorcraft_doc::{CharStyle, TextObject, TextRun}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut t = TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "", style(18.0, Color::BLACK)); + t.runs = runs + .iter() + .map(|(text, family, version)| { + let st = CharStyle { font_family: (*family).into(), font_version: version.map(Into::into), ..style(18.0, Color::BLACK) }; + TextRun::new(*text, st) + }) + .collect(); + let n = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + doc +} + +/// [`runs_doc`] saved and read back: the save's warnings, and the runs read back. +fn fonts_saved(runs: &[(&str, &str, Option<&str>)]) -> (Vec, Vec) { + let doc = runs_doc(runs); + let warnings = export_ai_editing(&doc, &opts()).unwrap().warnings; + let back = read_back(&doc); + let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type comes back as type") }; + (warnings, t.runs.iter().map(|r| (r.text.clone(), r.style.font_family.clone(), r.style.font_version.clone())).collect()) +} + +/// How many fonts the font set of [`runs_doc`] names, the hidden characters' font aside. +fn fonts_written(runs: &[(&str, &str, Option<&str>)]) -> usize { + text_document(&program(&runs_doc(runs))).matches("/CoolTypeFont").count() - 1 +} + +/// Type in a font installed beside a family spelled alike but for case and punctuation +/// (`Sample/Ink`, PostScript name `SampleInk`, beside `SAMPLE INK`), or beside a copy whose +/// PostScript name differs only in case (the type naming its version), is written under its +/// PostScript name and read back in its own family and version, so nothing about its font is +/// reported lost. +#[test] +fn type_in_a_font_beside_a_family_spelled_alike_keeps_its_font() { + install_copies(&[ + (true, ("SAVETEST INK", "Regular", "Version 1.000", "SAVETESTINK-Regular")), + (false, ("Savetest/Ink", "Regular", "Version 1.000", "SavetestInk")), + (true, ("CASESAVE INK", "Regular", "Version 1.000", "CASESAVEINK")), + (false, ("Casesave Ink", "Regular", "Version 2.000", "CasesaveInk")), + ]); + let (warnings, back) = fonts_saved(&[("Kept", "Savetest/Ink", None)]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back, [("Kept".to_string(), "Savetest/Ink".to_string(), None)]); + let (warnings, back) = fonts_saved(&[("Kept", "Casesave Ink", Some("Version 2.000"))]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back, [("Kept".to_string(), "Casesave Ink".to_string(), Some("Version 2.000".to_string()))]); +} + +/// Type in a static font's version that the family and style don't resolve to (two installed +/// copies of one PostScript name, family and style) is written with that version (`/5`) and comes +/// back in it, without a note; two runs in two versions are two fonts of the set. +#[test] +fn type_in_another_version_of_a_static_font_keeps_it() { + install_copies(&[(true, ("Rttest Ink", "Regular", "1.0", "RttestInk-Regular")), (false, ("Rttest Ink", "Regular", "1.1", "RttestInk-Regular"))]); + let (warnings, back) = fonts_saved(&[("Kept", "Rttest Ink", Some("1.1"))]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back, [("Kept".to_string(), "Rttest Ink".to_string(), Some("1.1".to_string()))]); + let (warnings, back) = fonts_saved(&[("Old", "Rttest Ink", None), ("New", "Rttest Ink", Some("1.1"))]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back, [("Old".to_string(), "Rttest Ink".to_string(), None), ("New".to_string(), "Rttest Ink".to_string(), Some("1.1".to_string()))]); + // A run naming the version its family and style resolve to anyway, or one that isn't + // installed, is in the same face as a run naming none: one font of the set, and no note. + let (warnings, back) = fonts_saved(&[("Same", "Rttest Ink", Some("1.0")), ("Gone", "Rttest Ink", Some("9.9")), ("Plain", "Rttest Ink", None)]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(back.iter().all(|(_, family, version)| family == "Rttest Ink" && version.is_none()), "{back:?}"); + assert_eq!(fonts_written(&[("Same", "Rttest Ink", Some("1.0")), ("Gone", "Rttest Ink", Some("9.9")), ("Plain", "Rttest Ink", None)]), 1); +} + +/// Type in the copy of a font that its family names, beside a copy of the same PostScript name in +/// another family that the name alone gives first, is written with its version (`/5`), so it comes +/// back in its own copy and family, without a note. +#[test] +fn type_in_a_copy_its_postscript_name_alone_doesnt_give_keeps_it() { + install_copies(&[ + (true, ("RvdupsaveInk", "Regular", "2.0", "RvdupsaveInk-Regular")), + (false, ("Rvdupsave Ink", "Regular", "1.0", "RvdupsaveInk-Regular")), + ]); + let (warnings, back) = fonts_saved(&[("Kept", "Rvdupsave Ink", None)]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back.iter().map(|r| r.1.as_str()).collect::>(), ["Rvdupsave Ink"]); + let (warnings, back) = fonts_saved(&[("Kept", "RvdupsaveInk", None)]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back.iter().map(|r| r.1.as_str()).collect::>(), ["RvdupsaveInk"]); +} + +/// A version string longer than the reader takes (256 bytes) isn't one it can pick a copy by: type +/// in such a copy, which its name alone doesn't give back, is reported as losing its font. +#[test] +fn a_version_longer_than_the_reader_takes_is_reported() { + let long = "9".repeat(300); + install_copies(&[ + (true, ("Longtest Ink", "Regular", "1.0", "LongtestInk-Regular")), + (false, ("Longtest Ink", "Regular", &long, "LongtestInk-Regular")), + ]); + let (warnings, back) = fonts_saved(&[("Long", "Longtest Ink", Some(&long))]); + assert_eq!(warnings, [writer::TYPE_ATTRIBUTES]); + assert_eq!(back, [("Long".to_string(), "Longtest Ink".to_string(), None)]); +} + /// On a large canvas, area type, threaded frames and type on a path stay type: the frames and /// lengths are written at the canvas's scale. Frames of a thread at two scales, and area type /// scaled by two factors, are written as outlines. diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 1e47ca1564..b161ec636f 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -338,6 +338,8 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { CharStyle { font_family: s.font_family.clone(), font_style: s.font_style.clone(), + // The font record's version (`/5`) names the face; whether it comes back is `font_kept`'s. + font_version: s.font_version.clone(), size: s.size, leading: s.leading, tracking: s.tracking.round(), @@ -452,15 +454,17 @@ struct FontRecord { /// the font's order, in the axis's own units. Empty for a static font, which has none: without /// it, an app that opens the file takes a variable font's default instance (#6388). design: Vec, - /// For a variable font, its version string (`/5`, name ID 5); empty for a static font. + /// Its version string (`/5`, name ID 5): for a variable font, and for a static font whose + /// PostScript name alone gives the reader another installed copy (two copies of one font, see + /// the reader's `document_font`); else empty. version: String, } -/// The font the font set names for style `st`: an installed face's PostScript name, and for a -/// variable font the setting of each of its axes (the named instance's, or the style's own -/// Variable Font setting, clamped to the axis as layout clamps it) and its version; else its family -/// and style as `family-style` (the family alone for Regular), the form the reader's -/// `family_style` splits. +/// The font the font set names for style `st`: an installed face's PostScript name, its version +/// for a variable font or when the name alone gives the reader another installed copy, and for a +/// variable font the setting of each of its axes (the named instance's, or the style's own Variable +/// Font setting, clamped to the axis as layout clamps it); else its family and style as +/// `family-style` (the family alone for Regular), the form the reader's `family_style` splits. fn font_name(st: &CharStyle) -> FontRecord { let db = vectorcraft_text::FontDb::global(); // The face of that family and style (the font database gives a fallback face for others). @@ -479,8 +483,11 @@ fn font_name(st: &CharStyle) -> FontRecord { set.map_or(own, |v| (v as f32).clamp(a.min.min(a.max), a.max.max(a.min))) }) .collect(); - let version = if design.is_empty() { String::new() } else { face.version.clone() }; - FontRecord { name: face.postscript_name(), truetype: face.is_truetype(), design, version } + let name = face.postscript_name(); + // The reader takes the face of a name alone as `postscript_face` finds it. + let by_name = crate::import::postscript_face(&name, None).is_some_and(|f| f.id() == face.id()); + let version = if design.is_empty() && by_name { String::new() } else { face.version.clone() }; + FontRecord { name, truetype: face.is_truetype(), design, version } } None if st.font_style.is_empty() || st.font_style == "Regular" => record(st.font_family.clone()), None => record(format!("{}-{}", st.font_family, st.font_style.replace(' ', ""))), @@ -518,12 +525,19 @@ fn fixed(v: f32) -> i64 { if v.is_finite() { (f64::from(v) * 65536.0).round() as i64 } else { 0 } } -/// Does the font of style `st` come back as itself: the family and style the reader makes of the -/// name it is written under (as its `char_style` does)? +/// Does the font of style `st` come back as itself: are the family and style the reader makes of +/// the record it is written as (its name, and its version as the reader takes it, as its +/// `char_style` does) the style's own, and the face they and the version read back resolve to the +/// face of the style? A version that isn't installed draws in the face the font resolves to anyway. fn font_kept(st: &CharStyle) -> bool { - let (family, style) = crate::family_style(&font_name(st).name); - let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); - family == st.font_family && style == st.font_style + let record = font_name(st); + let version = crate::import::record_version(&record.version); + let (family, style, read) = crate::import::document_font(&record.name, version); + let db = vectorcraft_text::FontDb::global(); + let face = |family: &str, style: &str, version: Option<&str>| db.face_version(family, style, version).map(|f| f.id()); + family == st.font_family + && style == st.font_style + && face(&family, &style, read.as_deref()) == face(&st.font_family, &st.font_style, st.font_version.as_deref()) } /// Whether `c` is a strong right-to-left letter (`Some(true)`: Hebrew, Arabic, Syriac, Thaana, NKo @@ -1022,13 +1036,13 @@ impl TextDoc { .iter() .map(|f| { let mut font = vec![(0, V::Str(f.name.clone())), (2, V::Int(i64::from(f.truetype)))]; - // A variable font's place in its design space and its version (#6388); a static - // font has neither. + // A variable font's place in its design space (#6388), and the version of a + // variable font or of a static one its name alone doesn't give back. if !f.design.is_empty() { font.push((4, V::List(f.design.iter().map(|v| V::Int(fixed(*v))).collect()))); - if !f.version.is_empty() { - font.push((5, V::Str(f.version.clone()))); - } + } + if !f.version.is_empty() { + font.push((5, V::Str(f.version.clone()))); } dict(vec![(99, V::Name("CoolTypeFont")), (0, dict(font))]) }) diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index b750efbc20..2cc4bed521 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -89,10 +89,11 @@ pub(crate) use ai::{ BRUSH_MANAGER, BrushesRead, MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, brush_plugin_name, brushes_kept, read_art_style, read_brush_object, read_style_list, style_moves_with, symbol_art_member, warp_number, }; -pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES}; +pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES, record_version}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; pub(crate) use native::{MAX_TEXT, scale_document}; pub use text::family_style; +pub(crate) use text::{document_font, postscript_face}; /// The page of a PostScript file without a bounding box (US Letter). const LETTER: [f64; 4] = [0.0, 0.0, 612.0, 792.0]; diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs index 6cdbb96747..043d6f2e4d 100644 --- a/crates/eps/src/import/ai/legacy_text.rs +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -508,10 +508,16 @@ impl Reader<'_> { None => (full.as_str(), None), }; st.cmap = cmap; - let (family, style) = super::super::super::text::family_style(name); + // The installed face of that PostScript name, as type from a text document + // takes it; else the name split into a family and style. + let text = super::super::super::text::named_font(name, None); + let (family, style, version) = text.unwrap_or_else(|| { + let (family, style) = super::super::super::text::family_style(name); + (family, style, None) + }); st.style.font_family = family; st.style.font_style = style; - st.style.font_version = None; + st.style.font_version = version; } if let Some(size) = vals.get(at + 1).and_then(V::num).filter(|s| s.is_finite() && *s > 0.0) { st.style.size = size.min(MAX_SIZE); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index c6a6041212..3c0ac39a05 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -3930,6 +3930,28 @@ fn point_type(attrs: &str, text: &str) -> String { format!("0 To\n1 0 0 1 10 50 0 Tp\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n{attrs}\n({text}) Tx\nTO\n") } +/// Legacy type is set in the installed face of exactly the PostScript name its `Tf` gives, as type +/// from a text document is: not a face whose name differs only in case and loads first, nor the +/// copy the family and style resolve to when the name's own is another (it names its version). +#[test] +fn legacy_type_takes_the_face_of_its_exact_postscript_name() { + use vectorcraft_text::test_fonts::named_copy; + let db = vectorcraft_text::FontDb::global(); + for (inter, names) in [ + (true, ("TWINLEGACY INK", "Regular", "Version 1.000", "TWINLEGACYINK-Regular")), + (false, ("Twinlegacy Ink", "Regular", "Version 2.000", "TwinlegacyInk-Regular")), + ] { + assert_eq!(db.add_font(named_copy(inter, names).unwrap()), 1, "{names:?}"); + } + let face = |font: &str| { + let texts = legacy_texts(&point_type("", "Hi").replace("/_Helvetica 12 Tf", &format!("/_{font} 12 Tf"))); + let f = db.style_face(&texts[0].runs[0].style).unwrap(); + (f.postscript_name(), f.version.clone()) + }; + assert_eq!(face("TwinlegacyInk-Regular"), ("TwinlegacyInk-Regular".to_string(), "Version 2.000".to_string())); + assert_eq!(face("TWINLEGACYINK-Regular"), ("TWINLEGACYINK-Regular".to_string(), "Version 1.000".to_string())); +} + /// `TC` and `TW` (§15.4.7) set the paragraph's letter and word spacing, `Tq` its hanging /// punctuation and `Th` (§22) its hyphenation; each paragraph keeps the values in force at its /// first character, and they last into the next text object. diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index efaed14199..d421498ff3 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -451,7 +451,8 @@ fn spend_tabs(tabs: &mut Vec, budget: &mut usize) -> bool { #[derive(Clone, Debug)] struct Style { - font: Option, + /// The font its record names, as the fonts the app has set it ([`DocFont::font`]). + font: Option, size: f64, /// `None`: auto. leading: Option, @@ -482,10 +483,44 @@ struct Style { standard_vertical_roman: bool, } +/// The longest font version string read from a font record: name ID 5 strings are short (`Version +/// 7.200`, or with the tools that built the font, some 80 characters). +const MAX_FONT_VERSION: usize = 256; + +/// The version a font record's key 5 gives `v`, as the reader takes it: `None` when it is empty or +/// longer than [`MAX_FONT_VERSION`] bytes. The `.ai` save checks a record it writes against this. +pub(crate) fn record_version(v: &str) -> Option<&str> { + (!v.is_empty() && v.len() <= MAX_FONT_VERSION).then_some(v) +} + +/// A font as type is set in it: family, style and the version it names +/// ([`super::text::document_font`]). +type Font = (String, String, Option); + +/// A font of a text document's font list, as its record (a `CoolTypeFont`) names it. +#[derive(Debug, Default)] +struct DocFont { + /// Key 0: the PostScript name. + name: String, + /// Key 5, when the record keeps it: the font's version string (name ID 5), which tells installed + /// copies of one font apart. + version: Option, + /// [`Self::font`]'s answer, found once for every run that names the record. + font: std::sync::OnceLock, +} + +impl DocFont { + /// The font type set in this record's font takes: the installed copy it names + /// ([`super::text::document_font`]). + fn font(&self) -> &Font { + self.font.get_or_init(|| super::text::document_font(&self.name, self.version.as_deref())) + } +} + /// The text document of an editing copy. pub(super) struct Texts { stories: Vec, - fonts: Vec, + fonts: Vec, /// The character and paragraph styles a story starts from. defaults: Val, para_defaults: Val, @@ -544,7 +579,15 @@ impl Texts { .get("0")? .list() .iter() - .map(|f| f.get("0").and_then(|f| f.get("0")).and_then(|f| f.get("0")).and_then(Val::str).unwrap_or_default().to_string()) + .map(|f| { + let record = f.get("0").and_then(|f| f.get("0")); + let text = |k: &str| record.and_then(|r| r.get(k)).and_then(Val::str); + DocFont { + name: text("0").unwrap_or_default().to_string(), + version: text("5").and_then(record_version).map(str::to_string), + font: std::sync::OnceLock::new(), + } + }) .collect(); let main = doc.get("1")?; let sheets = |key: &str| -> Vec { @@ -765,12 +808,12 @@ impl Texts { let pick = |k: &str| chain.iter().find_map(|s| s.get(k)).or_else(|| self.defaults.get(k)); let num = |k: &str| pick(k).and_then(Val::num); let on = |k: &str| !matches!(pick(k), Some(Val::Bool(false))); - let font = num("0").and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.is_empty()).cloned(); + let font = num("0").and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.name.is_empty()); let size = num("1").filter(|v| *v > 0.0 && *v < 1e5).unwrap_or(12.0); // Key 5 of a paint names the art style that paints it ([`Self::with_patterns`]). let pattern = |k: &str| pick(k).and_then(|p| p.get("5")).and_then(Val::str).and_then(|name| self.patterns.get(name)).cloned(); Style { - font, + font: font.map(|f| f.font().clone()), size, leading: if on("4") { None } else { num("5").filter(|v| *v > 0.0 && *v < 1e5) }, tracking: num("8").filter(|v| v.abs() < 1e5).unwrap_or(0.0), @@ -1322,13 +1365,12 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle { } } -/// The character style of character attributes `s`: its font from the fonts the app has, its -/// lengths (size, leading, baseline shift, stroke width) scaled by `scale`, and its patterns placed -/// in the type's text space by `to_text` (the document → its text space), where they stay as the -/// document has them. +/// The character style of character attributes `s`: its font from the fonts the app has (the +/// installed copy its font record names, [`DocFont::font`]), its lengths (size, leading, baseline +/// shift, stroke width) scaled by `scale`, and its patterns placed in the type's text space by +/// `to_text` (the document → its text space), where they stay as the document has them. fn char_style(s: &Style, scale: f64, to_text: Affine) -> CharStyle { - let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style); - let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); + let (family, style, version) = s.font.clone().unwrap_or_else(|| (CharStyle::default().font_family, "Regular".to_string(), None)); // A fill or stroke that is on takes its pattern in place of its colour. let paint = |color: Option, pattern: &Option| match (color, pattern) { // A pattern that can't be placed (the type's text space out of range) keeps the colour. @@ -1343,6 +1385,7 @@ fn char_style(s: &Style, scale: f64, to_text: Affine) -> CharStyle { CharStyle { font_family: family, font_style: style, + font_version: version, size: s.size * scale, // Auto leading stays auto: the layout gives each line the paragraph's share of the largest // type on it, as the file's own layout does ("The largest leading value in a line of type diff --git a/crates/eps/src/import/tests_fontnames.rs b/crates/eps/src/import/tests_fontnames.rs index d925905ee7..a2a8d45641 100644 --- a/crates/eps/src/import/tests_fontnames.rs +++ b/crates/eps/src/import/tests_fontnames.rs @@ -63,3 +63,37 @@ fn abbreviated_styles_in_postscript_names_are_spelled_out() { fn an_installed_face_of_the_postscript_name_names_the_font() { assert_eq!(crate::family_style("ABCDEF+SourceSans3-Semibold"), ("Source Sans 3".to_string(), "Semibold".to_string())); } + +/// The PostScript name and version of the face each `show` of a program that sets `fonts` in turn +/// draws its type in. +fn faces_shown(fonts: &[&str]) -> Vec<(String, String)> { + let body: String = fonts.iter().map(|f| format!("/{f} findfont 12 scalefont setfont 10 20 moveto (Hi) show\n")).collect(); + let eps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{body}showpage\n%%EOF\n"); + let r = import(eps.as_bytes()).unwrap(); + let mut faces = vec![]; + r.document.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + let face = FontDb::global().style_face(&t.runs[0].style).unwrap(); + faces.push((face.postscript_name(), face.version.clone())); + } + }); + faces +} + +/// Type a PostScript program sets is drawn in the installed face of exactly the font's PostScript +/// name, as type from a text document is: not another family spelled alike but for case and +/// punctuation, nor a face whose name differs only in case and loads first. +#[test] +fn shown_type_takes_the_face_of_its_exact_postscript_name() { + use vectorcraft_text::test_fonts::named_copy; + for (inter, names) in [ + (true, ("FUZZSHOW INK", "Regular", "Version 1.000", "FUZZSHOWINK-Regular")), + (false, ("Fuzzshow/Ink", "Regular", "Version 1.000", "FuzzshowInk")), + (true, ("CASESHOW INK", "Regular", "Version 1.000", "CASESHOWINK")), + (false, ("Caseshow Ink", "Regular", "Version 2.000", "CaseshowInk")), + ] { + assert_eq!(FontDb::global().add_font(named_copy(inter, names).unwrap()), 1, "{names:?}"); + } + let names: Vec = faces_shown(&["FuzzshowInk", "CaseshowInk", "CASESHOWINK"]).into_iter().map(|f| f.0).collect(); + assert_eq!(names, ["FuzzshowInk", "CaseshowInk", "CASESHOWINK"]); +} diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 687834c97b..40338adf5b 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -3128,6 +3128,73 @@ fn type_from_the_text_document_takes_its_character_styles_attributes() { assert_eq!(style(2), (solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), Some(18.0)), "the run's own colour"); } +/// The PostScript name and version of the face that type from a text document whose font record is +/// `record` (in place of `/0 (Helvetica)`) is set in. +fn face_set_from(record: &str) -> (String, String) { + let doc = text_document(&[("Hamburg\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]).replacen("/0 (Helvetica)", record, 1); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{record}: {:?}", r.warnings); + let face = vectorcraft_text::FontDb::global().style_face(&texts[0].runs[0].style).unwrap(); + (face.postscript_name(), face.version.clone()) +} + +/// Installs the copies of each font `copies` lists, the first of each loaded first. +fn install(copies: &[(bool, vectorcraft_text::test_fonts::CopyNames<'_>)]) { + for (inter, names) in copies { + let font = vectorcraft_text::test_fonts::named_copy(*inter, *names).unwrap(); + assert_eq!(vectorcraft_text::FontDb::global().add_font(font), 1, "{names:?}"); + } +} + +/// Type from the text document is set in the installed face of exactly the PostScript name its +/// font record gives, whatever other installed copies answer to that name too: one whose family +/// is spelled alike but for case and punctuation (`SAMPLE INK` beside `Sample/Ink`, PostScript name +/// `SampleInk`), one whose PostScript name differs only in case or punctuation (in its own family or +/// another), or another copy of the same family and style under another PostScript name (an older +/// build of the font: the type names its version). Those copies set the type at other widths. +#[test] +fn type_from_the_text_document_takes_the_face_of_its_exact_postscript_name() { + install(&[ + (true, ("FUZZTEST INK", "Regular", "Version 1.000", "FUZZTESTINK-Regular")), + (false, ("Fuzztest/Ink", "Regular", "Version 1.000", "FuzztestInk")), + (true, ("CASETEST INK", "Regular", "Version 1.000", "CASETESTINK")), + (false, ("Casetest Ink", "Regular", "Version 2.000", "CasetestInk")), + (true, ("Copytest Ink", "Regular", "Version 1.000", "CopytestInk-Light")), + (false, ("Copytest Ink", "Regular", "Version 2.000", "CopytestInk-Regular")), + (true, ("NeuetestInk", "Bold", "Version 1.000", "NeuetestInkBold")), + (false, ("Neuetest Ink", "Bold", "Version 1.000", "NeuetestInk-Bold")), + ]); + assert_eq!(face_set_from("/0 (FuzztestInk)").0, "FuzztestInk", "not the family spelled alike"); + assert_eq!(face_set_from("/0 (FUZZTESTINK-Regular)").0, "FUZZTESTINK-Regular"); + assert_eq!(face_set_from("/0 (CasetestInk)").0, "CasetestInk", "not the name in other case, loaded first"); + assert_eq!(face_set_from("/0 (CASETESTINK)").0, "CASETESTINK"); + assert_eq!(face_set_from("/0 (CopytestInk-Regular)"), ("CopytestInk-Regular".to_string(), "Version 2.000".to_string())); + assert_eq!(face_set_from("/0 (CopytestInk-Light)"), ("CopytestInk-Light".to_string(), "Version 1.000".to_string())); + assert_eq!(face_set_from("/0 (NeuetestInk-Bold)").0, "NeuetestInk-Bold", "not the name without its hyphen, in another family"); + // A name in a case no installed face has still finds the font. + assert_eq!(face_set_from("/0 (casetestink)").0, "CASETESTINK"); +} + +/// A font record may keep the font's version string (key 5, name ID 5): of two installed copies +/// with one PostScript name, family and style, the type is set in that version, also when the +/// record spells the name with other punctuation. A version that isn't installed, or a record +/// without one, gives the copy the family and style resolve to. +#[test] +fn type_from_the_text_document_takes_the_version_its_font_record_keeps() { + install(&[(true, ("Vertest Ink", "Regular", "1.0", "VertestInk-Regular")), (false, ("Vertest Ink", "Regular", "1.1", "VertestInk-Regular"))]); + let version = |record: &str| face_set_from(record).1; + assert_eq!(version("/0 (VertestInk-Regular) /5 (1.1)"), "1.1"); + assert_eq!(version("/0 (VertestInk-Regular) /5 (1.0)"), "1.0"); + assert_eq!(version("/0 (VertestInk-Regular)"), "1.0"); + assert_eq!(version("/0 (VertestInk-Regular) /5 (9.9)"), "1.0"); + assert_eq!(version("/0 (VertestInk-Regular) /5 ()"), "1.0"); + // A record whose name differs from the copies' in punctuation alone still picks its version. + assert_eq!(version("/0 (Vertest-Ink-Regular) /5 (1.1)"), "1.1"); + assert_eq!(version("/0 (Vertest-Ink-Regular)"), "1.0"); +} + /// A story with no runs, and a run or paragraph that names no style, is set in `[Normal Character /// Style]` and `[Normal Paragraph Style]` before the text document's base attributes. A Large /// Canvas copy (1:10) writes the styles at the art's scale (1.2 for 12 points, 0.6 for a 6-point diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 989450f440..515e0b12ec 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -42,20 +42,75 @@ fn style_word(w: &str) -> String { .to_string() } +/// A font's PostScript name without a subset prefix (`ABCDEF+`) or the `*1` of a re-encoded copy +/// (`ABCDEF+RollerBabyBV*1` is `RollerBabyBV`). +fn bare_name(name: &str) -> &str { + let name = match name.split_once('+') { + Some((prefix, rest)) if prefix.len() == 6 && prefix.bytes().all(|b| b.is_ascii_uppercase()) => rest, + _ => name, + }; + match name.rsplit_once('*') { + Some((base, n)) if !base.is_empty() && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) => base, + _ => name, + } +} + +/// The font of type that a text document's font record names: by `name`, its PostScript name, and +/// `version`, the version string (name ID 5) the record keeps, if any. An installed face of that +/// name gives its font ([`named_font`]). Without one, `name` is split ([`family_style`]) and the +/// family spelled as an available family is ([`FontDb::find_family`]: `MicrosoftYaHei` is +/// Microsoft YaHei). → (family, style, version). +/// +/// [`FontDb::find_family`]: vectorcraft_text::FontDb::find_family +pub(crate) fn document_font(name: &str, version: Option<&str>) -> (String, String, Option) { + named_font(name, version).unwrap_or_else(|| { + let (family, style) = family_style(name); + let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); + (family, style, None) + }) +} + +/// The font of type set in the installed face of PostScript name `name` (a subset prefix and a +/// re-encoded copy's `*1` aside) in `version` ([`postscript_face`]): that face's own family and +/// style, and its version when the family and style alone resolve to another installed copy of +/// the font. A library can keep two copies under one family and style that differ in their +/// PostScript names or their versions, and draw the same text at different widths. → (family, +/// style, version); `None` when no installed face has the name. +pub(crate) fn named_font(name: &str, version: Option<&str>) -> Option<(String, String, Option)> { + let face = postscript_face(bare_name(name), version)?; + // As the PDF import names a version: only one that the family and style don't resolve to. + let default = vectorcraft_text::FontDb::global().face(&face.family, &face.style); + let version = default.filter(|d| d.version != face.version).map(|_| face.version.clone()); + Some((face.family.clone(), face.style.clone(), version)) +} + +/// The installed face of PostScript name `name` (without a subset prefix), among the installed +/// faces that answer to it ([`FontDb::postscript_faces`]): those of exactly that name when there +/// are any (PostScript names are case-sensitive, so `SampleSans` isn't the face named +/// `SAMPLESANS`, and `SampleSerif-Bold` isn't `SampleSerifBold`), and of those the one in +/// `version` when it is installed, else the first. `None` when no installed face has the name. +/// +/// [`FontDb::postscript_faces`]: vectorcraft_text::FontDb::postscript_faces +pub(crate) fn postscript_face(name: &str, version: Option<&str>) -> Option> { + let db = vectorcraft_text::FontDb::global(); + let found = db.find_postscript(name)?; + if found.postscript_name() == name && version.is_none_or(|v| v == found.version) { + return Some(found); + } + let faces = db.postscript_faces(name); + let named: Vec<_> = faces.iter().filter(|f| f.postscript_name() == name).cloned().collect(); + let pool = if named.is_empty() { faces } else { named }; + let in_version = version.and_then(|v| pool.iter().find(|f| f.version == v).cloned()); + Some(in_version.or_else(|| pool.into_iter().next()).unwrap_or(found)) +} + /// A font's family and style from its PostScript name, without a subset prefix (`ABCDEF+`) or the /// `*1` of a re-encoded copy (`ABCDEF+RollerBabyBV*1`): an installed or bundled face of that /// exact name gives its own (a family can hold hyphens: `rounded-x-mplus-1c-black` is Rounded-X /// M+ 1c, black); else the name is split, `Helvetica-BoldOblique` → (`Helvetica`, `Bold /// Oblique`), `TimesNewRomanPS-BoldMT` → (`Times New Roman`, `Bold`). pub fn family_style(name: &str) -> (String, String) { - let name = match name.split_once('+') { - Some((prefix, rest)) if prefix.len() == 6 && prefix.bytes().all(|b| b.is_ascii_uppercase()) => rest, - _ => name, - }; - let name = match name.rsplit_once('*') { - Some((base, n)) if !base.is_empty() && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) => base, - _ => name, - }; + let name = bare_name(name); if let Some(face) = vectorcraft_text::FontDb::global().find_postscript(name) { return (face.family.clone(), face.style.clone()); } @@ -551,11 +606,11 @@ impl Interp<'_> { if !(size.is_finite() && size > 1e-3 && size < 1e5) { return ps_err("undefinedresult", "a font size"); } - let (family, font_style) = family_style(&name); - // The available family's own spelling: `MicrosoftYaHei` is Microsoft YaHei, not "Microsoft Ya Hei". - let font_family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); + // The installed face of that PostScript name, as type from a text document takes it; else + // the available family's own spelling: `MicrosoftYaHei` is Microsoft YaHei, not "Microsoft Ya Hei". + let (font_family, font_style, font_version) = document_font(&name, None); let fill = if self.g.paint.is_none() { Paint::solid(vectorcraft_color::Color::BLACK) } else { self.g.paint.clone() }; - let style = CharStyle { font_family, font_style, size, fill, stroke: Paint::None, ..CharStyle::default() }; + let style = CharStyle { font_family, font_style, font_version, size, fill, stroke: Paint::None, ..CharStyle::default() }; let mut t = TextObject::point(Point::ZERO, text, style); t.vertical = vertical; let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); diff --git a/crates/text/src/fontdb/lookup.rs b/crates/text/src/fontdb/lookup.rs index fa91e97875..23e00afa0c 100644 --- a/crates/text/src/fontdb/lookup.rs +++ b/crates/text/src/fontdb/lookup.rs @@ -15,6 +15,9 @@ use super::{ Ordering, Size, }; +/// The most faces [`FontDb::postscript_faces`] gives, and the most families it loads. +const MAX_POSTSCRIPT_FACES: usize = 16; + impl FontDb { /// The family's own name and the style for a name given in a document: any of a family's /// names (`ヒラギノ角ゴシック` is Hiragino Sans), a legacy family name (`Hiragino Sans W6` with its @@ -141,6 +144,33 @@ impl FontDb { loaded(self) } + /// Every installed face that [`find_postscript`](Self::find_postscript) could give for `name` + /// (its PostScript name ignoring case and anything but letters and digits), its answer first, + /// then the others in load order, at most 16 in all: copies of one font in + /// other families (`SampleSerif` beside `Sample Serif`) or versions, and names that differ + /// only in case. Each family that has one is loaded from the system fonts whole, as + /// [`versions`](Self::versions) loads one. Empty when no installed font has the name. + pub fn postscript_faces(&self, name: &str) -> Vec> { + let Some(first) = self.find_postscript(name) else { return vec![] }; + let key = norm(name); + let mut families: Vec = vec![first.family.clone()]; + if let Some(v) = self.aliases.read().unwrap_or_else(|e| e.into_inner()).get(&key) { + for a in v.iter().filter(|a| a.paired.is_none() && a.style.is_some()) { + if families.len() < MAX_POSTSCRIPT_FACES && !families.iter().any(|f| f.eq_ignore_ascii_case(&a.family)) { + families.push(a.family.clone()); + } + } + } + for family in &families { + self.load_family_files(family); + } + let mut out = vec![first.clone()]; + let faces = self.read_faces(); + let others = faces.iter().filter(|f| f.id != first.id && f.keys.postscript.contains(&key)); + out.extend(others.take(MAX_POSTSCRIPT_FACES - 1).cloned()); + out + } + /// The (family, style) of the installed face whose PostScript name is `name` (ignoring ASCII /// case). Documents name fonts this way, and a family can hold hyphens /// ("Rounded-X-Mplus-1c-black" is Rounded-X M+ 1c, black), so the name can't be split. diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 2d766ae571..a75ee8d3af 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -1594,6 +1594,30 @@ pub fn twin_font(newer: bool) -> Option> { Some(builder.build()) } +/// The names of a [`named_copy`]: family, style, version string and PostScript name. +pub type CopyNames<'a> = (&'a str, &'a str, &'a str, &'a str); + +/// The bundled Source Sans 3 Regular or, `inter`, the bundled Inter Regular (glyphs of other +/// widths) under `names`, as an installed copy of a font: a font library can keep copies whose +/// names differ only in case or punctuation, or whose family and style are one but whose PostScript +/// names or versions differ. `None` when it can't be built. +pub fn named_copy(inter: bool, (family, style, version, postscript): CopyNames<'_>) -> Option> { + const SOURCE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let font = skrifa::FontRef::new(if inter { INTER } else { SOURCE }).ok()?; + let full = format!("{family} {style}"); + let name = name_table(&[(1, family), (2, style), (4, &full), (5, version), (6, postscript)])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`numbered_font`]: styles numbered W3 and W6, and no Regular. pub const NUMBERED_FAMILY: &str = "Numtest Mincho"; diff --git a/crates/text/src/tests/versions.rs b/crates/text/src/tests/versions.rs index a721e435b1..ca28d7e4ef 100644 --- a/crates/text/src/tests/versions.rs +++ b/crates/text/src/tests/versions.rs @@ -41,3 +41,26 @@ fn type_naming_a_version_is_set_in_it() { assert!((newer - older).abs() > 50.0, "the two versions set the line differently: {newer} vs {older}"); assert_eq!(width(&db, Some(VERSIONS[1])), newer); } + +/// Every installed face a PostScript name answers to, ignoring case and punctuation: the face +/// `find_postscript` gives first, then the copies in other families and the versions, in load +/// order; none for a name no face has. +#[test] +fn every_face_of_a_postscript_name_is_found() { + use crate::test_fonts::named_copy; + let db = FontDb::with_font_dirs(vec![]); + for (inter, names) in [ + (true, ("Facetest Ink", "Bold", "Version 1.000", "FacetestInkBold")), + (false, ("Facetest-Ink", "Bold", "Version 1.000", "FacetestInk-Bold")), + (true, ("Facetest-Ink", "Bold", "Version 2.000", "FACETESTINK-BOLD")), + (false, ("Other Ink", "Bold", "Version 1.000", "OtherInk-Bold")), + ] { + assert_eq!(db.add_font(named_copy(inter, names).expect("the test font builds")), 1, "{names:?}"); + } + let names = |name: &str| db.postscript_faces(name).iter().map(|f| f.postscript_name()).collect::>(); + let first = db.find_postscript("FacetestInk-Bold").expect("a face").postscript_name(); + assert_eq!(first, "FacetestInkBold"); + assert_eq!(names("FacetestInk-Bold"), ["FacetestInkBold", "FacetestInk-Bold", "FACETESTINK-BOLD"]); + assert_eq!(names("OtherInk-Bold"), ["OtherInk-Bold"]); + assert!(names("NoSuchInk-Bold").is_empty()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 20cce4636a..cf6e0a8a09 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (`.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; #6865: explicitly requested oversized editing artboard; PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (`.ai` and EPS import: type from the text document, type a PostScript program sets and legacy `.ai` type are set in an installed face of their font's PostScript name, exact name first, in the version a text document's record keeps; the `.ai` save writes a static font's version when its name alone gives another copy, #6983; `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; #6865: explicitly requested oversized editing artboard; PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). @@ -59,7 +59,7 @@ private editing data. VectorCraft: and the paragraph's hyphenation options and tab stops. Stroked type keeps its stroke's cap, join and miter limit. Type filled or stroked with a pattern keeps its pattern, placed in the document as a path's pattern fill would be: a character's paint names an art style of one paint, and the pattern is in that style (its colour in the text document is black). A pattern the file - doesn't define, or a style that isn't a pattern, keeps the colour as before. Area type of one + doesn't define, or a style that isn't a pattern, keeps the colour as before. Its font is an installed face of the PostScript name its font record gives: one of exactly that name, case and punctuation included (`SampleSans` isn't the face named `SAMPLESANS`, nor `SampleSerif-Bold` the one named `SampleSerifBold`), when one is installed, else one whose name differs only in them. The type takes that face's own family and style, not another family spelled alike (`SAMPLE INK` beside `Sample/Ink`, whose PostScript name is `SampleInk`). Among those faces the record's version (key 5, name ID 5) picks the one in that version when it is installed; a version that isn't installed gives the first, without a note. The type names the face's version when its family and style alone resolve to another installed copy. Copies that share their family, style and version (an OpenType and a TrueType build of one release) can't be told apart: only the first of them loads. Type that a PostScript program sets, and legacy `.ai` type, take the face of their font's name the same way, with no version to read. Area type of one frame whose last lines the file sets but which don't fit here (the fallback font, or a descent past the frame's bottom) 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, with a warning when it does. RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb` @@ -132,7 +132,7 @@ private editing data. VectorCraft: and paragraph attributes are left out with a warning. A font of the text document's font set that is a variable font also names where in its design space the type is set (each `fvar` axis in the font's order, as 16.16 fixed: the named instance's setting, or the run's own Variable Font setting) and its version string, so the type doesn't open at the - font's default instance (#6388); two settings of one font are two fonts of the set, and a static font names neither. Each run's baseline direction is written in the form the reader + font's default instance (#6388); two settings of one font are two fonts of the set. A static font names no design space, and names its version when its PostScript name alone would give the reader another installed copy (another version, or a copy of that name in another family), so the type opens in its own copy again; two such copies are two fonts of the set. A version longer than the reader takes (256 bytes) can't pick a copy: type in a copy its name alone doesn't give back is then reported as losing attributes. Each run's baseline direction is written in the form the reader below reads it: 1 when Standard Vertical Roman Alignment is on, 2 when it is off and 3 for Tate-chu-yoko; the document's defaults and the normal character style carry 1. A run with both Tate-chu-yoko and Standard Vertical Roman Alignment is written as Tate-chu-yoko, with the type attributes warning. Type in opacity masks, pattern tiles, symbols and art cut to a @@ -322,6 +322,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | `.ai` and EPS import (#6983): type from the text document is set in an installed face of the PostScript name its font record gives, one of exactly that name first. That face's family is no longer swapped for another family spelled alike but for case and punctuation (`FontDb::find_family`, which picked `SAMPLE INK` for `SampleInk`, the PostScript name of `Sample/Ink`); among the faces `FontDb::postscript_faces` lists for the name, the import takes one whose name matches in case and punctuation before one that matches only without them; and when the family and style alone resolve to another installed copy, the type names the face's version. A font record's key 5, the version string (name ID 5), picks that version among the faces of its PostScript name, also when the record spells the name with other punctuation (`Sample-Slab` for faces named `SampleSlab`); each record is resolved once. Type a PostScript program sets and legacy `.ai` type take the face of their font's name the same way. The `.ai` save writes a static font's version (`/5`) when its PostScript name alone would give the reader another installed copy (another version, or the same name in another family), so two such copies are two fonts of the set, and checks that the type comes back in the same family, style and face, taking the record's version as the reader does (up to 256 bytes): such type round-trips without the type attributes note, and so does a version that isn't installed, which draws in the face the font resolves to anyway. In 224 users' files (used locally, never committed; all PDF-compatible, so the PostScript and legacy paths are not exercised), 16 files set some type in another installed face; 12 render differently against their own page, 10 closer (in one from 2.55% to 0.01% of the page differing) and 2 further by 0.03% of the page (an envelope of type that now opens live, and a copy of the same widths in the version the record names); envelopes of type that open live go from 154 to 159, none fewer in any file; no file opens in another way. The notes of how far a file's layers differ from its page shrink in 2 files and appear in 2 where an envelope of type now opens live, so the file is compared with its page. Two installed copies of one PostScript name, family and style that differ in widths, in files whose records keep no version, still take the copy the family and style resolve to: only the page's widths tell them apart. Tests: `tests_native::type_from_the_text_document_takes_the_face_of_its_exact_postscript_name`, `tests_native::type_from_the_text_document_takes_the_version_its_font_record_keeps`, `tests_fontnames::shown_type_takes_the_face_of_its_exact_postscript_name`, `ai::tests::legacy_type_takes_the_face_of_its_exact_postscript_name`, `ai_editing::tests::type_in_a_font_beside_a_family_spelled_alike_keeps_its_font`, `ai_editing::tests::type_in_another_version_of_a_static_font_keeps_it`, `ai_editing::tests::type_in_a_copy_its_postscript_name_alone_doesnt_give_keeps_it`, `ai_editing::tests::a_version_longer_than_the_reader_takes_is_reported`, `vectorcraft-text` `tests::versions::every_face_of_a_postscript_name_is_found`. Scores unchanged. | | 2026-11-12 | minor | `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern. In the text document a character's fill or stroke is a colour (key 0) and the name of an art style (key 5). In users' files (used locally, never committed) that style is one paint (`/SimpleStyle`): a colour, the one key 0 already has, or a pattern, `(name) … [matrix] p`, whose key 0 is black. Such type came in black, or stroked in black. It now takes the pattern, its matrix in art space as a path's pattern fill has it (the files' PDF parts tile such type with that matrix), placed in the type's text space so that it stays where the document has it. A pattern the file doesn't define or can't place, and a style of any other paint, keep the colour as before. A `.ai` saved again writes such type as its outlines, as it writes all type with pattern paint, with that warning. On 224 of our own files: envelopes of type that open live go from 154 in 45 files to 157 in 46, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 10 of the 13 files with patterned type, 8 closer to the file's own page (one by under 0.01 points, in a file whose render differs from its page almost everywhere either way), 1 further by 0.03 points of its pixels (an envelope of type there now opens live in place of its drawn art), 1 not compared (its page renders at another size); notes change in 3 files: one loses its type note, one loses the note for type in an envelope and, now compared with its page, gains its type notes, and one that opens from its PDF part names a smaller difference. Tests `ate::tests::a_run_painted_by_a_patterns_art_style_takes_the_pattern`, `a_runs_pattern_stays_where_the_document_has_it`, `tests_native::type_painted_with_a_pattern_takes_it_from_the_art_style_its_paint_names`. Scores and estimates unchanged. (#6856) | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-12 | minor | PDF import (#5235): a glyph with no advance in a font the file writes horizontally (a vowel sign or anusvara set over its letter) stays on its line instead of opening as vertical type of its own. A line of type in an Indic script (U+0900 to U+0DFF) is set again after it is read and compared with the glyphs the file drew: when it would draw other glyphs or put them elsewhere (a vowel sign the file draws before its consonant, a conjunct the file doesn't name), the line keeps the file's glyphs as outlines, with a note. Indic type in a font that isn't installed never draws as the file does, so it keeps the file's glyphs too; other type in such a font still shows in the fallback font (#4568). Tests: `crates/pdf/src/tests/reshaped.rs`. Scores unchanged. | From 34caacc63ba35e0fee2e9f7e8641a0f40ff2ae71 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:05:14 -0500 Subject: [PATCH 05/17] Library panels: the list fills a taller panel above its footer (#2992) --- crates/ui-egui/src/panels/library_panel.rs | 12 ++++++-- .../ui-egui/src/panels/library_panel/tests.rs | 29 +++++++++++++++++++ docs/ui-parity.md | 4 ++- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 292fd498c5..948651ef80 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -635,9 +635,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { clicked = Some((row.name.to_string(), row.item.is_none(), ui.input(|i| i.modifiers))); } }; + // In a sized panel the list fills the space above the footer (#2992), as the Artboards list + // does; elsewhere it keeps its 220 pt (280 pt for large thumbnails). + let fixed = widgets::panel_footer_bottom(ui).is_some(); + let list_height = widgets::panel_list_height(ui, if view == View::LargeThumb { 280.0 } else { 220.0 }, 2.0 + ui.spacing().item_spacing.y); widgets::list_box(ui, |ui| { - let max_height = if view == View::LargeThumb { 280.0 } else { 220.0 }; - egui::ScrollArea::vertical().id_salt(key(&panel, "scroll")).max_height(max_height).show(ui, |ui| { + let scroll = egui::ScrollArea::vertical() + .id_salt(key(&panel, "scroll")) + .max_height(list_height) + .min_scrolled_height(list_height.min(64.0)) + .auto_shrink([false, !fixed]); + scroll.show(ui, |ui| { ui.set_width(ui.available_width()); if rows.is_empty() { widgets::dim_label(ui, if query.trim().is_empty() { tl!("This library is empty.") } else { tl!("No matches.") }); diff --git a/crates/ui-egui/src/panels/library_panel/tests.rs b/crates/ui-egui/src/panels/library_panel/tests.rs index 10acd4603e..034a90e5b0 100644 --- a/crates/ui-egui/src/panels/library_panel/tests.rs +++ b/crates/ui-egui/src/panels/library_panel/tests.rs @@ -690,3 +690,32 @@ fn the_panel_menu_has_persistent() { assert!(text.contains(item), "{item}: {text}"); } } + +/// In a panel made taller the library list fills the space above the panel's footer instead of +/// scrolling inside 220 pt (#2992); outside a sized panel it keeps that height. +#[test] +fn the_list_fills_a_tall_panel_above_its_footer() { + let mut app = app(); + let ctx = context(); + app.run("window.swatchLibrary", json!({"library": "web-safe-216"})).unwrap(); + let panel = "library:swatches:web-safe-216"; + app.run("ui.libraryPanel", json!({"panel": panel, "view": "smallList"})).unwrap(); + // The clip rect of the rows' names (the colors' hex codes): the list's visible area. + let mut list = |bottom: Option| { + let mut clip = None; + for _ in 0..3 { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 800.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + let info = egui::UiStackInfo::default().with_tag_value(widgets::PANEL_FOOTER, bottom); + ui.scope_builder(egui::UiBuilder::new().ui_stack_info(info), |ui| crate::dock::panel_body(&mut app, ui, panel, 256.0, 760.0)); + }); + out.textures_delta.clear(); + clip = out.shapes.iter().find(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text().starts_with('#'))).map(|c| c.clip_rect); + } + clip.expect("a row is drawn") + }; + let tall = list(Some(700.0)); + assert!(tall.height() > 400.0 && tall.bottom() <= 700.0 - 31.0, "the list fills the panel above its footer: {tall:?}"); + let loose = list(None); + assert!(loose.height() <= 220.5, "unsized, the list keeps its height: {loose:?}"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e5f9b1cf19..6d093a8a1a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -51,6 +51,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Library panels: in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992). Scores unchanged. | | 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | | 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. | @@ -165,6 +166,7 @@ tools, never checked side by side) isn't captured row by row. | Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); curve geometry: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), open ends curving in, the same rule for the rubber band, dragged, inserted and converted points (#1236, in part); neighbours of a point dragged on an existing path keep their tangents, and modifiers requested in #975 remain | | Icon-strip flyout resizing (#5713) | done | Canvas-side edge and bottom; user size persists after reopening and workspace restore; Glyphs and Artboards pointer/cursor regression | | Icon-strip flyout resizing from the top (storytold/craft-libs#1094) | done | Top edge (a 4 pt band over the tab strip) and the canvas-side top corner; the bottom and the icon-column edge stay; the moved top persists after reopening and workspace restore; `an_icon_flyout_grows_upward_from_its_top_edge_and_free_corner` | +| Library panels' lists fill a taller panel (#2992) | done | Swatch (Color Books among them), brush, symbol and graphic style library panels: docked, floating or in a resized flyout, the list takes the height above the panel's footer; outside a sized panel it stays 220 pt (280 pt for large thumbnails); `the_list_fills_a_tall_panel_above_its_footer` in `panels/library_panel/tests.rs` | | Layers panel lists a blend's spine (#4820) | done | an open blend shows a `` row below its key objects, where Release leaves the spine; a click selects the blend (Shift/Cmd toggles it) so Direct Selection edits the spine; a locked blend's row selects nothing; no row while searching or filtering (`a_blends_spine_is_a_row_below_its_keys_that_selects_the_blend`) | | Layers panel: a layer whose Layer Options › Print is off shows its name italic (#5739) | done | the panel row and its drag ghost; a printing layer stays upright, and Print on again restores it; `panels::layers` test `a_layer_that_does_not_print_has_its_name_italic` | | Blend spine Anchor Point conversion and handle editing (#2544) | implemented | symmetric handles, independent drag/retraction, visible-handle policy and selection/stacking covered by tool and engine regressions; Windows native canvas checks and screenshots reviewed in PR #2643 | From ce42acea1dccf09502f6a9fe371312aecc974e5e Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:54:09 -0500 Subject: [PATCH 06/17] Library panels (#2992): parity and roadmap revision rows --- docs/roadmap.md | 3 ++- docs/target-app-parity.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 4c78d258c9..89f7e8e10a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -52,6 +52,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. | | 2026-11-10 | minor | M13 / G6: MCP catalogue (#6460): headless lists UI commands, eight prompts, 33 tools, effect vs command ids; scores and estimates unchanged. | | 2026-11-08 | minor | Character Styles panel: a double-click on a user style's row opens its Character Style Options (`ui.charStyleOptions`); the dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8938bfce54..ea04f7efd5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (#6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -53,6 +53,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Swatches panel: its menu's Show Recent Colors hides and shows the panel's Recent Colors row, checked by default and kept between sessions apart from the Color panel's (#6617); scores unchanged. | | 2026-11-09 | minor | Stroke panel: Line Arrow, a 41st generated arrowhead: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, a sharp tip and square-cut ends, twice as wide as the other heads; bounds and clicks follow each head's own reach (`stroke.set` `LineArrow`, #5421); scores and estimates unchanged | | 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. A 3D effect on a fill or stroke that builds nothing (Revolve after Roughen) no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports: the fill or stroke draws as without it. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`, `fx::tests::a_3d_effect_that_builds_nothing_draws_the_item_as_without_it`. Scores and estimates unchanged. | From 5f320422a7f497c95d7b80042ecf39b8546527d4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 17:11:22 -0800 Subject: [PATCH 07/17] Fonts: Find Font skips patterns at 0% opacity and follows inline graphics - A fill or stroke at an opacity of 0 no longer counts as painting its pattern, so Find, selection-only Change and Print leave that pattern's type out. - Text whose inline graphics draw a symbol shows that symbol's type: Find selects it, and selection-only Change and the font list reach the type through it, also through a pattern whose art holds such text. - Refresh Font List lays out again the type in opacity masks and in the art of symbols and patterns, and inline graphics, on the layers and in that art, take the new size of their symbols' art. --- crates/engine/src/cmd/fonts.rs | 234 +++++++++++++++++++++++++++++++-- docs/mcp.md | 3 +- docs/target-app-parity.md | 2 +- 3 files changed, 224 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 74cece5af3..7df3e57b2a 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "Select Text by Font", [], None, - "{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, the instances of the symbols whose art uses it, the objects painted with a pattern whose art uses it and the objects whose opacity mask uses it (in type that is not hidden or locked in that art), that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; a pattern swatch that nothing paints, or a layer's mask, selects nothing; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}", + "{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, the instances of the symbols whose art uses it, the text whose inline graphics draw such a symbol, the objects painted with a pattern whose art uses it (by a visible fill or stroke whose opacity is above 0, a stroke wider than 0) and the objects whose opacity mask uses it (in type that is not hidden or locked in that art), that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; a pattern swatch that nothing paints, or a layer's mask, selects nothing; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}", has_doc, select_font ), @@ -108,8 +108,14 @@ fn pattern_being_edited(d: &Document, name: &str) -> bool { d.pattern_edit.as_ref().is_some_and(|e| e.pattern == name) } +/// Whether fill or stroke `i` paints: it is visible, its opacity is above 0 and, a stroke, it is +/// wider than 0. +fn paints(i: &AppearanceItem) -> bool { + i.visible() && i.opacity() > 0.0 && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0) +} + /// The patterns that the fills and strokes `n` draws name (of a text object, also those of its -/// characters): visible ones, and strokes wider than 0. +/// characters): those that [`paints`] holds for, and character strokes wider than 0. fn paint_patterns(n: &Node) -> impl Iterator { let runs = match &n.kind { NodeKind::Text(t) => t.runs.as_slice(), @@ -118,7 +124,7 @@ fn paint_patterns(n: &Node) -> impl Iterator { n.appearance .items .iter() - .filter(|i| i.visible() && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0)) + .filter(|i| paints(i)) .map(AppearanceItem::paint) .chain(runs.iter().flat_map(|r| [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)]).flatten()) .filter_map(|p| match p { @@ -127,6 +133,15 @@ fn paint_patterns(n: &Node) -> impl Iterator { }) } +/// The symbols that the inline graphics in the type of `n` draw. +fn inline_symbols(n: &Node) -> impl Iterator { + let runs = match &n.kind { + NodeKind::Text(t) => t.runs.as_slice(), + _ => &[], + }; + runs.iter().filter_map(|r| r.inline.as_ref().map(|art| art.symbol.as_str())) +} + /// Symbols and patterns, by name: those whose art is in scope. #[derive(Clone, Debug, Default)] struct Resources { @@ -135,12 +150,14 @@ struct Resources { } impl Resources { - /// Adds what `n` itself shows: the symbol of an instance, the patterns its paints name, and - /// what the art of its opacity mask shows (not hidden or locked objects of that art). + /// Adds what `n` itself shows: the symbol of an instance, the symbols of the inline graphics in + /// its type, the patterns its paints name, and what the art of its opacity mask shows (not + /// hidden or locked objects of that art). fn add_own(&mut self, n: &Node) { if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { self.symbols.insert(symbol.clone()); } + self.symbols.extend(inline_symbols(n).map(str::to_string)); self.patterns.extend(paint_patterns(n).map(str::to_string)); if let Some(m) = n.extras().mask.as_deref() { self.add(&m.art); @@ -486,8 +503,7 @@ impl Printed<'_> { NodeKind::Text(t) => drawn_runs(t), _ => (vec![], false), }; - let items: Vec<&AppearanceItem> = - n.appearance.items.iter().filter(|i| i.visible() && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0)).collect(); + let items: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| paints(i)).collect(); let patterns: Vec<&str> = items .iter() .map(|i| i.paint()) @@ -910,13 +926,15 @@ fn replace(s: &mut Session, p: &Value) -> Result { } /// Whether `n` or a descendant that is not hidden or locked, nor in a hidden or locked group, -/// shows type in a font `uses` matches: text set in it, an instance of a symbol in `found`, a paint -/// naming a pattern in `found`, or such type in the art of its opacity mask. +/// shows type in a font `uses` matches: text set in it, an instance of a symbol in `found`, an +/// inline graphic of a symbol in `found`, a paint naming a pattern in `found`, or such type in the +/// art of its opacity mask. fn reaches(n: &Node, uses: &impl Fn(&TextObject) -> bool, found: &Resources) -> bool { n.visible && !n.locked && (matches!(&n.kind, NodeKind::Text(t) if uses(t)) || matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol)) + || inline_symbols(n).any(|sym| found.symbols.contains(sym)) || paint_patterns(n).any(|p| found.patterns.contains(p)) || n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, uses, found)) || n.children().is_some_and(|ch| ch.iter().any(|c| reaches(c, uses, found)))) @@ -964,13 +982,15 @@ fn select_font(s: &mut Session, p: &Value) -> Result { found.patterns.extend(patterns); } // The objects the Selection tool can reach that show such type: the instances of those - // symbols, the objects that those patterns paint, and the objects whose opacity mask holds it - // (not layers, which the Selection tool doesn't select). + // symbols, the text whose inline graphics draw them, the objects that those patterns paint, + // and the objects whose opacity mask holds it (not layers, which the Selection tool doesn't + // select). let masked: HashSet = masked.into_iter().collect(); if !found.symbols.is_empty() || !found.patterns.is_empty() || !masked.is_empty() { let texts: HashSet = hits.iter().copied().collect(); d.walk(&mut |n: &Node| { let shows = matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol)) + || inline_symbols(n).any(|sym| found.symbols.contains(sym)) || paint_patterns(n).any(|p| found.patterns.contains(p)) || (masked.contains(&n.id) && n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, &uses, &found))); if shows && !n.is_layer() && !texts.contains(&n.id) && editable(n.id) { @@ -1069,6 +1089,37 @@ fn font_list(_: &mut Session, p: &Value) -> Result { } } +/// Gives the inline graphics in the type of [`art_texts_of`] the current bounds of their symbols' +/// art, and lays out again the type whose inline graphics changed. A symbol drawn inline in the +/// art of another symbol takes one more round, so there is at most one round per symbol. +fn resolve_art_inline(doc: &mut Document) { + for _ in 0..=doc.symbols.len() { + let names: BTreeSet = + art_texts_of(doc).iter().flat_map(|(_, t, _)| t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.symbol.clone()))).collect(); + let bounds: BTreeMap> = names + .into_iter() + .map(|name| { + let b = doc.symbol_art_bounds(&name); + (name, b) + }) + .collect(); + let stale = |t: &TextObject| t.runs.iter().any(|r| r.inline.as_ref().is_some_and(|a| bounds.get(&a.symbol).is_some_and(|b| *b != a.bounds))); + let mut changed = false; + edit_art_texts_of(doc, &stale, &mut |t| { + for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) { + if let Some(b) = bounds.get(&a.symbol) { + a.bounds = *b; + } + } + refresh_bounds(t); + changed = true; + }); + if !changed { + break; + } + } +} + pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result { let db = vectorcraft_text::FontDb::global(); #[cfg(not(target_arch = "wasm32"))] @@ -1076,7 +1127,9 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result { #[cfg(target_arch = "wasm32")] let faces = 0; // The canvas redraws type in the fonts available now (renderers lay it out again when the - // fonts change), and the bounds follow; that isn't an edit to save. + // fonts change), and the bounds follow, also of the type in opacity masks and in the art of + // symbols and patterns; that isn't an edit to save. The type in art is laid out first, and the + // inline graphics of the type on the layers then take the new bounds of their symbols' art. for d in &mut s.docs { let mut texts = vec![]; d.doc.walk(|n| { @@ -1084,9 +1137,12 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result { texts.push(n.id); } }); - if !texts.is_empty() { + if !texts.is_empty() || !art_texts_of(&d.doc).is_empty() { d.change_unsaved(|doc| { let doc = std::sync::Arc::make_mut(doc); + edit_art_texts_of(doc, &|_| true, &mut refresh_bounds); + resolve_art_inline(doc); + doc.resolve_inline_art(); for id in texts { if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { refresh_bounds(t); @@ -1751,6 +1807,158 @@ mod tests { assert_eq!(symbol_texts(&s.doc().unwrap().doc, None)[0].0.runs[0].style.font_family, "Source Sans 3"); } + /// A pattern in a fill whose opacity is 0 paints nothing: Find doesn't select the object for + /// the pattern's type, a selection-only Change leaves that type in its font, and Print doesn't + /// count its font. + #[test] + fn find_font_skips_patterns_at_an_opacity_of_0() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let painted = s.execute("shape.rectangle", &json!({"x": 0, "y": 20, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [painted]})).unwrap(); + let printed = |s: &Session| { + let d = &s.doc().unwrap().doc; + printed_type(d, &d.layers, &[Rect::new(-1000.0, -1000.0, 1000.0, 1000.0)]).0.into_iter().map(|(family, ..)| family).collect::>() + }; + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1); + assert_eq!(printed(&s), ["Source Serif 4"]); + s.edit("Clear the fill", |d, _| { + for it in d.node_mut(NodeId(painted)).unwrap().appearance.items.iter_mut() { + if let AppearanceItem::Fill(f) = it { + f.opacity = 0.0; + } + } + Ok(()) + }) + .unwrap(); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 0); + assert!(printed(&s).is_empty(), "{:?}", printed(&s)); + s.execute("select.set", &json!({"ids": [painted]})).unwrap(); + let r = s + .execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Source Sans 3"}, "selectionOnly": true})) + .unwrap(); + assert_eq!(r, json!({"runs": 0})); + assert_eq!(pattern_families(&s, "Dots"), ["Source Serif 4"]); + } + + /// Text whose inline graphic draws a symbol shows the symbol's type: Find selects that text + /// and the objects painted with a pattern whose art holds it, and a selection-only Change + /// changes the symbol's type. + #[test] + fn find_font_reaches_symbols_through_inline_graphics() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let serif = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Se", "font": "Source Serif 4"})).unwrap()["id"].clone(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [serif]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let inline = |s: &mut Session, y: i32| { + let id = s.execute("text.create", &json!({"x": 10, "y": y, "text": "In", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": id, "at": 0, "symbol": "Mark"})).unwrap(); + id + }; + let listed = inline(&mut s, 60); + let tiled = inline(&mut s, 100); + s.execute("object.pattern.make", &json!({"ids": [tiled], "name": "Loop", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [tiled]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let painted = s.execute("shape.rectangle", &json!({"x": 0, "y": 140, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Loop", "ids": [painted]})).unwrap(); + let mut instances = 0; + s.doc().unwrap().doc.walk(|n| instances += usize::from(matches!(n.kind, NodeKind::SymbolInstance { .. }))); + assert_eq!(instances, 0, "the symbol is drawn only by inline graphics"); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 2); + let mut selected = s.doc().unwrap().selection.objects.clone(); + selected.sort(); + assert_eq!(selected, [NodeId(listed), NodeId(painted)]); + s.execute("select.set", &json!({"ids": [painted]})).unwrap(); + let rows = font_rows(&mut s, json!({"selectionOnly": true})); + assert_eq!(rows, [("Inter".to_string(), 2, 1), ("Source Serif 4".to_string(), 1, 1)]); + let r = s + .execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Source Sans 3"}, "selectionOnly": true})) + .unwrap(); + assert_eq!(r, json!({"runs": 1})); + assert_eq!(symbol_texts(&s.doc().unwrap().doc, None)[0].0.runs[0].style.font_family, "Source Sans 3"); + } + + /// Refresh Font List lays out again the type in opacity masks and in the art of symbols and + /// patterns, as it does the type on the layers, and the inline graphics of the type on the + /// layers follow the symbols' type as laid out again. + #[test] + fn rescanning_fonts_refreshes_the_bounds_of_type_in_art() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let owner = s.execute("shape.rectangle", &json!({"x": 0, "y": 20, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap(); + let masked = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Mask", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + mask_with(&mut s, owner, masked); + let tiled = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "Tile", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.pattern.make", &json!({"ids": [tiled], "name": "Dots", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [tiled]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let stale = Rect::new(0.0, 0.0, 1.0, 1.0); + s.edit("Make the bounds stale", |d, _| { + edit_art_texts_of(d, &|_| true, &mut |t| t.cached_bounds = Some(stale)); + Ok(()) + }) + .unwrap(); + let bounds = |s: &Session| art_texts_of(&s.doc().unwrap().doc).iter().map(|(_, t, _)| t.cached_bounds).collect::>(); + assert_eq!(bounds(&s), [Some(stale), Some(stale)]); + s.execute("text.rescanFonts", &json!({})).unwrap(); + let after = bounds(&s); + assert_eq!(after.len(), 2); + assert!(after.iter().all(|b| b.is_some_and(|b| b != stale && b.width() > 1.0)), "{after:?}"); + let marked = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Se", "font": "Inter"})).unwrap()["id"].clone(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [marked]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let line = s.execute("text.create", &json!({"x": 10, "y": 140, "text": "In", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": line, "at": 0, "symbol": "Mark"})).unwrap(); + s.edit("Make the bounds stale", |d, _| { + edit_art_texts_of(d, &|_| true, &mut |t| t.cached_bounds = Some(stale)); + d.resolve_inline_art(); + Ok(()) + }) + .unwrap(); + let resolved = |s: &Session| { + let d = &s.doc().unwrap().doc; + matches!(d.inline_resolved(text(d, NodeId(line)).unwrap()), std::borrow::Cow::Borrowed(_)) + }; + assert!(resolved(&s), "the inline graphic took the stale bounds"); + s.execute("text.rescanFonts", &json!({})).unwrap(); + assert!(resolved(&s), "the inline graphic follows the symbol's type laid out again"); + // So does an inline graphic in the art of another symbol. + let held = s.execute("text.create", &json!({"x": 10, "y": 180, "text": "Out", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": held, "at": 0, "symbol": "Mark"})).unwrap(); + s.execute("symbol.new", &json!({"name": "Outer", "ids": [held]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + s.edit("Make the bounds stale", |d, _| { + edit_art_texts_of(d, &|_| true, &mut |t| t.cached_bounds = Some(stale)); + Ok(()) + }) + .unwrap(); + let shrunk = s.doc().unwrap().doc.symbol_art_bounds("Mark"); + s.edit("Take the stale bounds", |d, _| { + edit_art_texts_of(d, &|_| true, &mut |t| { + for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) { + a.bounds = shrunk; + } + }); + Ok(()) + }) + .unwrap(); + let nested = |s: &Session| { + let d = &s.doc().unwrap().doc; + art_texts_of(d).iter().flat_map(|(_, t, _)| t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.bounds))).collect::>() + }; + assert_eq!(nested(&s), [shrunk]); + s.execute("text.rescanFonts", &json!({})).unwrap(); + let mark = s.doc().unwrap().doc.symbol_art_bounds("Mark"); + assert_ne!(mark, shrunk); + assert_eq!(nested(&s), [mark], "the inline graphic in the art of Outer follows Mark's type laid out again"); + } + /// The font family of text object `id`'s first run. fn text_family(s: &Session, id: u64) -> String { text(&s.doc().unwrap().doc, NodeId(id)).unwrap().runs[0].style.font_family.clone() diff --git a/docs/mcp.md b/docs/mcp.md index 207ca19d57..4a664fd195 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9059,7 +9059,8 @@ font's variation axes (`tag`, `name`, `min`, `default`, `max`, `hidden`, and `va first style), empty for a static font. `text.rescanFonts` (Character panel menu › Refresh Font List) scans the font folders again, for fonts installed or removed since the app started: type set in a font that became available redraws in it, and type set in a family that was removed redraws in -the fallback font and is listed as missing, without editing the document. The app does so by +the fallback font and is listed as missing, without editing the document. The type in opacity masks and in the art of +symbols and pattern swatches is laid out again too, and inline graphics take the new size of their symbols' art. The app does so by itself when its window comes to the front and a font folder changed meanwhile. ```json diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e2f536bdeb..81d149fb58 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1203,7 +1203,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Recent font pickers (#3515):** The shared Character, Properties, Glyphs and Find Font family picker lists recently applied families first, once each, in most-recent order and limited by Preferences › Type › Number of Recent Fonts. Search and filters apply to both sections; unavailable families are omitted. Section headings cannot be selected or previewed. Confirmed character-range font edits update the same saved history; previews and failed edits do not. Requirement: [issue #3515](https://github.com/storytold/vectorcraft/issues/3515). - **Find Font: Replace With Font From:** a dropdown under Replace With Font chooses the list the replacement font comes from: Document lists the families the document uses, once each; Recent lists the recently applied families that are installed (fonts applied with Find Font's Change and Change All included), newest first, as many as Preferences › Type › Number of Recent Fonts allows; System, the default, lists the installed families available in the font menu. The dialog opens on System with Source Sans 3 chosen, or the font menu's first family when Source Sans 3 is not installed. Choosing a list keeps the chosen font when the list has it and it is installed; otherwise Document and Recent choose the list's first installed font, and System chooses Source Sans 3 or the font menu's first family. Change and Change All are unavailable when the list has no installed font (Recent before any font was applied) and when the chosen family is not installed or is not in the shown list. Behavior reference: [Find and replace fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-replace-fonts.html) (public documentation). Tests: `crates/ui-egui/src/find_font.rs`. - **Select Text by Font: scope (a VectorCraft addition, #5529):** `select.font` takes `scope`: `"document"` (the default, as before), `{artboards: [index, …]}` or `{layers: [id, …]}` (sublayers included). On an artboard means what All on Active Artboard takes (the bounds overlap it or the center is on it; one shared test, `select::on_artboard`); hidden and locked text stays out as before; a symbol instance counts where the instance is. Type › Find Font…'s Find In dropdown passes the active artboard or the selected layers (else the current layer). Tests: `crates/engine/src/cmd/fonts.rs`, `crates/ui-egui/src/find_font.rs`, `crates/engine/tests/command_sweep.rs`. -- **Find Font: type in opacity masks and pattern swatches:** Type › Find Font…'s Document list (`text.fonts`), Find (`select.font`), Change and Change All (`text.replaceFont`), Replace Missing Fonts (`addon.cleanup.replaceFonts`), the Missing Fonts dialog (`text.missingFonts`), Document Info's Fonts and Font Details for the whole document (`document.info`), File › Package and the export warning about fonts that aren't available include the type in opacity masks (on objects and layers, disabled masks and masks inside mask, symbol or pattern art included) and in the art of pattern swatches, also a swatch that nothing paints with. A pattern's type is listed and changed once, however many objects the pattern paints. Find selects the objects whose mask holds the font and the objects painted with a pattern whose art uses it (VectorCraft's own rule; a layer's mask and a swatch that nothing paints with select nothing); Change and Change All leave the type in the mask of a hidden or locked object in its font and count it in `skipped`, and Change does the same with the type of a pattern that only hidden or locked selected objects paint with. The help page (https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-replace-fonts.html) says the Document list holds all the fonts the document uses. Tests: `crates/engine/src/cmd/fonts.rs`, `crates/engine/src/cmd/addons/cleanup/text/tests.rs`, `crates/engine/src/cmd/docinfo.rs`. +- **Find Font: type in opacity masks and pattern swatches:** Type › Find Font…'s Document list (`text.fonts`), Find (`select.font`), Change and Change All (`text.replaceFont`), Replace Missing Fonts (`addon.cleanup.replaceFonts`), the Missing Fonts dialog (`text.missingFonts`), Document Info's Fonts and Font Details for the whole document (`document.info`), File › Package and the export warning about fonts that aren't available include the type in opacity masks (on objects and layers, disabled masks and masks inside mask, symbol or pattern art included) and in the art of pattern swatches, also a swatch that nothing paints with. A pattern's type is listed and changed once, however many objects the pattern paints. Find selects the objects whose mask holds the font, the text whose inline graphics draw a symbol whose art uses it, and the objects painted with a pattern whose art uses it by a visible fill or stroke above 0% opacity (VectorCraft's own rule; a layer's mask and a swatch that nothing paints with select nothing); Change and Change All leave the type in the mask of a hidden or locked object in its font and count it in `skipped`, and Change does the same with the type of a pattern that only hidden or locked selected objects paint with. The help page (https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-replace-fonts.html) says the Document list holds all the fonts the document uses. Tests: `crates/engine/src/cmd/fonts.rs`, `crates/engine/src/cmd/addons/cleanup/text/tests.rs`, `crates/engine/src/cmd/docinfo.rs`. - **Mouse wheel over the font family field (#5253):** In the Character and Properties panels and the Control bar, turning the mouse wheel over the closed font family field applies the next family to the selected type (the previous one when turned up), keeping the style when the family has it, else its closest. Each notch is one undo step; leaving the field or Escape keeps the family, and Undo takes it back. The steps use the font menu's order at the first turn and keep it until the pointer leaves the field or the text selection changes, so recent-font updates do not reshuffle later turns. That order follows the menu's saved font filters but ignores its search text, puts recent families first, and stops at the first and last family. A turn with a modifier key is left to the panel and the canvas. Behavior reference: the [Illustrator CS6 reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf#page=10) describes editing hovered dropdown controls with the mouse wheel; [Find and apply fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-apply-fonts.html) names the family field's places. Applying it to the font field, the order, the ends and the undo steps are VectorCraft's own rules. Tests: `crates/ui-egui/src/tests/font_menu.rs`, `crates/ui-egui/src/font_menu/tests.rs`, `crates/ui-egui/tests/font_wheel.rs`. - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, Text Flow by rows or by columns (#4978), vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`), and an anchor of the path of type on a path outside perspective, the type reflowing along it (`text.reshapePath`, #3980). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). A double-click on area type's bottom middle handle turns on Auto Size (#1940). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches back to the tool that was active before type editing began (kept while clicks move editing to other text), or to the Selection tool when there was none (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). From 5d71656ca7f106dd93b0d2f28a64ba4dc1230e6a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 17:35:19 -0800 Subject: [PATCH 08/17] Fonts: Find Font skips the patterns of overset characters Characters of area type or type on a path that don't fit draw nothing, so their character fill or stroke no longer counts as painting its pattern for Find and selection-only Change, as Print already leaves them out. --- crates/engine/src/cmd/fonts.rs | 83 ++++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 7df3e57b2a..c793f56479 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -114,23 +114,30 @@ fn paints(i: &AppearanceItem) -> bool { i.visible() && i.opacity() > 0.0 && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0) } +/// The name of pattern paint `p`. +fn pattern_name(p: &Paint) -> Option<&str> { + match p { + Paint::Pattern { pattern, .. } => Some(pattern.as_str()), + _ => None, + } +} + +/// The fill and, when it is wider than 0, the stroke of the characters of `r`. +fn run_paints(r: &TextRun) -> impl Iterator { + [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)].into_iter().flatten() +} + /// The patterns that the fills and strokes `n` draws name (of a text object, also those of its -/// characters): those that [`paints`] holds for, and character strokes wider than 0. -fn paint_patterns(n: &Node) -> impl Iterator { - let runs = match &n.kind { - NodeKind::Text(t) => t.runs.as_slice(), - _ => &[], - }; - n.appearance - .items - .iter() - .filter(|i| paints(i)) - .map(AppearanceItem::paint) - .chain(runs.iter().flat_map(|r| [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)]).flatten()) - .filter_map(|p| match p { - Paint::Pattern { pattern, .. } => Some(pattern.as_str()), - _ => None, - }) +/// characters): those that [`paints`] holds for, and the character paints of the runs that draw +/// (as [`drawn_runs`] finds them: not the overset runs of area type or type on a path). +fn paint_patterns(n: &Node) -> Vec<&str> { + let mut out: Vec<&str> = n.appearance.items.iter().filter(|i| paints(i)).filter_map(|i| pattern_name(i.paint())).collect(); + if let NodeKind::Text(t) = &n.kind + && t.runs.iter().flat_map(run_paints).any(|p| pattern_name(p).is_some()) + { + out.extend(drawn_runs(t).0.into_iter().flat_map(run_paints).filter_map(pattern_name)); + } + out } /// The symbols that the inline graphics in the type of `n` draw. @@ -158,7 +165,7 @@ impl Resources { self.symbols.insert(symbol.clone()); } self.symbols.extend(inline_symbols(n).map(str::to_string)); - self.patterns.extend(paint_patterns(n).map(str::to_string)); + self.patterns.extend(paint_patterns(n).into_iter().map(str::to_string)); if let Some(m) = n.extras().mask.as_deref() { self.add(&m.art); } @@ -935,7 +942,7 @@ fn reaches(n: &Node, uses: &impl Fn(&TextObject) -> bool, found: &Resources) -> && (matches!(&n.kind, NodeKind::Text(t) if uses(t)) || matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol)) || inline_symbols(n).any(|sym| found.symbols.contains(sym)) - || paint_patterns(n).any(|p| found.patterns.contains(p)) + || paint_patterns(n).into_iter().any(|p| found.patterns.contains(p)) || n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, uses, found)) || n.children().is_some_and(|ch| ch.iter().any(|c| reaches(c, uses, found)))) } @@ -991,7 +998,7 @@ fn select_font(s: &mut Session, p: &Value) -> Result { d.walk(&mut |n: &Node| { let shows = matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol)) || inline_symbols(n).any(|sym| found.symbols.contains(sym)) - || paint_patterns(n).any(|p| found.patterns.contains(p)) + || paint_patterns(n).into_iter().any(|p| found.patterns.contains(p)) || (masked.contains(&n.id) && n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, &uses, &found))); if shows && !n.is_layer() && !texts.contains(&n.id) && editable(n.id) { hits.push(n.id); @@ -1845,6 +1852,44 @@ mod tests { assert_eq!(pattern_families(&s, "Dots"), ["Source Serif 4"]); } + /// Characters of area type that don't fit its frame paint nothing: their pattern's type isn't + /// found through them until they are drawn. + #[test] + fn find_font_skips_patterns_of_overset_characters() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let area = s + .execute( + "text.create", + &json!({"x": 10, "y": 60, "text": "A\nB", "font": "Source Sans 3", "size": 20, "area": {"width": 100, "height": 40}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("text.setRangeStyle", &json!({"id": area, "start": 2, "end": 3, "font": "Inter"})).unwrap(); + let paint_run = |s: &mut Session, letter: &'static str| { + s.edit("Paint a run with the pattern", |d, _| { + if let Some(NodeKind::Text(t)) = d.node_mut(NodeId(area)).map(|n| &mut n.kind) { + for r in t.runs.iter_mut().filter(|r| r.text.contains(letter)) { + r.style.fill = Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }; + } + } + Ok(()) + }) + .unwrap(); + }; + paint_run(&mut s, "B"); + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), text(&s.doc().unwrap().doc, NodeId(area)).unwrap()); + assert!(laid.overflow, "the second line doesn't fit the frame"); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 0); + paint_run(&mut s, "A"); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1); + } + /// Text whose inline graphic draws a symbol shows the symbol's type: Find selects that text /// and the objects painted with a pattern whose art holds it, and a selection-only Change /// changes the symbol's type. From 48e5f09371a581b00cc33deacd9df9fabd3538ff Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 18:37:17 -0800 Subject: [PATCH 09/17] Fonts: Find Font skips inline graphics that draw nothing An inline graphic in overset characters, or one named on a run that holds other characters, draws no symbol, so Find, selection-only Change and Print no longer reach its symbol's type through it. --- crates/engine/src/cmd/fonts.rs | 91 ++++++++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index c793f56479..3119887ad3 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -122,7 +122,13 @@ fn pattern_name(p: &Paint) -> Option<&str> { } } -/// The fill and, when it is wider than 0, the stroke of the characters of `r`. +/// Whether `r` is laid out as an inline graphic: it has one and holds only its character. +fn draws_inline(r: &TextRun) -> bool { + r.inline.is_some() && vectorcraft_text::edit::is_inline_text(&r.text) +} + +/// The fill and, when it is wider than 0, the stroke of the characters of `r` (of an inline +/// graphic too: they paint its underline, strikethrough and ruby). fn run_paints(r: &TextRun) -> impl Iterator { [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)].into_iter().flatten() } @@ -140,13 +146,15 @@ fn paint_patterns(n: &Node) -> Vec<&str> { out } -/// The symbols that the inline graphics in the type of `n` draw. -fn inline_symbols(n: &Node) -> impl Iterator { - let runs = match &n.kind { - NodeKind::Text(t) => t.runs.as_slice(), - _ => &[], - }; - runs.iter().filter_map(|r| r.inline.as_ref().map(|art| art.symbol.as_str())) +/// The symbols that the inline graphics in the type of `n` draw (as [`drawn_runs`] finds them: not +/// those in the overset runs of area type or type on a path). +fn inline_symbols(n: &Node) -> Vec<&str> { + match &n.kind { + NodeKind::Text(t) if t.runs.iter().any(draws_inline) => { + drawn_runs(t).0.into_iter().filter(|r| draws_inline(r)).filter_map(|r| r.inline.as_ref().map(|art| art.symbol.as_str())).collect() + } + _ => vec![], + } } /// Symbols and patterns, by name: those whose art is in scope. @@ -164,7 +172,7 @@ impl Resources { if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { self.symbols.insert(symbol.clone()); } - self.symbols.extend(inline_symbols(n).map(str::to_string)); + self.symbols.extend(inline_symbols(n).into_iter().map(str::to_string)); self.patterns.extend(paint_patterns(n).into_iter().map(str::to_string)); if let Some(m) = n.extras().mask.as_deref() { self.add(&m.art); @@ -511,15 +519,7 @@ impl Printed<'_> { _ => (vec![], false), }; let items: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| paints(i)).collect(); - let patterns: Vec<&str> = items - .iter() - .map(|i| i.paint()) - .chain(runs.iter().flat_map(|r| [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)]).flatten()) - .filter_map(|p| match p { - Paint::Pattern { pattern, .. } => Some(pattern.as_str()), - _ => None, - }) - .collect(); + let patterns: Vec<&str> = items.iter().map(|i| i.paint()).chain(runs.iter().flat_map(|r| run_paints(r))).filter_map(pattern_name).collect(); let brushed = vectorcraft_brush::has_brush(n); if (mask.is_some() || !patterns.is_empty() || brushed) && areas.is_none_or(|a| !open || self.meets(n, a)) { if let Some(m) = mask { @@ -550,7 +550,7 @@ impl Printed<'_> { for r in runs { self.fonts.extend(run_fonts(r)); // Inline graphics draw their symbols' art. - if let Some(art) = &r.inline { + if let Some(art) = r.inline.as_ref().filter(|_| draws_inline(r)) { self.symbol(&art.symbol); } } @@ -941,7 +941,7 @@ fn reaches(n: &Node, uses: &impl Fn(&TextObject) -> bool, found: &Resources) -> && !n.locked && (matches!(&n.kind, NodeKind::Text(t) if uses(t)) || matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol)) - || inline_symbols(n).any(|sym| found.symbols.contains(sym)) + || inline_symbols(n).into_iter().any(|sym| found.symbols.contains(sym)) || paint_patterns(n).into_iter().any(|p| found.patterns.contains(p)) || n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, uses, found)) || n.children().is_some_and(|ch| ch.iter().any(|c| reaches(c, uses, found)))) @@ -997,7 +997,7 @@ fn select_font(s: &mut Session, p: &Value) -> Result { let texts: HashSet = hits.iter().copied().collect(); d.walk(&mut |n: &Node| { let shows = matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol)) - || inline_symbols(n).any(|sym| found.symbols.contains(sym)) + || inline_symbols(n).into_iter().any(|sym| found.symbols.contains(sym)) || paint_patterns(n).into_iter().any(|p| found.patterns.contains(p)) || (masked.contains(&n.id) && n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, &uses, &found))); if shows && !n.is_layer() && !texts.contains(&n.id) && editable(n.id) { @@ -1890,6 +1890,55 @@ mod tests { assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1); } + /// An inline graphic that doesn't fit its area type draws nothing, and neither does an inline + /// graphic named on a run of other characters: Find doesn't select the text for its symbol. + #[test] + fn find_font_skips_undrawn_inline_graphics() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let mark = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Se", "font": "Inter"})).unwrap()["id"].clone(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [mark]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let area = s + .execute( + "text.create", + &json!({"x": 10, "y": 60, "text": "A\nB", "font": "Source Sans 3", "size": 20, "area": {"width": 100, "height": 40}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("text.insertInline", &json!({"id": area, "at": 3, "symbol": "Mark"})).unwrap(); + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), text(&s.doc().unwrap().doc, NodeId(area)).unwrap()); + assert!(laid.overflow && laid.inlines.is_empty(), "the inline graphic doesn't fit the frame"); + assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 0); + let point = s.execute("text.create", &json!({"x": 10, "y": 140, "text": "Hi", "font": "Source Sans 3"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": point, "at": 0, "symbol": "Mark"})).unwrap(); + assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(point)]); + // A run that names an inline graphic but holds other characters is laid out as those + // characters: its fill paints them, and it draws no symbol. + let plain = s.execute("text.create", &json!({"x": 10, "y": 180, "text": "Ok", "font": "Source Sans 3"})).unwrap()["id"].as_u64().unwrap(); + let art = text(&s.doc().unwrap().doc, NodeId(point)).unwrap().runs.iter().find_map(|r| r.inline.clone()).unwrap(); + s.edit("Name an inline graphic on plain characters", |d, _| { + if let Some(NodeKind::Text(t)) = d.node_mut(NodeId(plain)).map(|n| &mut n.kind) { + for r in &mut t.runs { + r.inline = Some(art.clone()); + r.style.fill = Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }; + } + } + Ok(()) + }) + .unwrap(); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(plain)]); + assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(point)]); + } + /// Text whose inline graphic draws a symbol shows the symbol's type: Find selects that text /// and the objects painted with a pattern whose art holds it, and a selection-only Change /// changes the symbol's type. From 79954e36cb21e3df2ea463be2cf35498e768cd02 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:45:59 -0500 Subject: [PATCH 10/17] Library panels (#2992): M3 and area row notes --- docs/roadmap.md | 2 +- docs/target-app-parity.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 89f7e8e10a..bc469700ed 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -148,7 +148,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M0 | Skeleton + vertical slice | ✅ done | — | | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | -| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | +| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | Library panels' lists (swatch, Color Book, brush, symbol and graphic style libraries) fill a taller panel above its footer (#2992; scores and estimates unchanged); 4–8 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 41e9c80a41..8aa2b020fd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -495,7 +495,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of similar groups, transform nuances (the rotated persistent bounding box is done) | 4–6 | | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | -| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | +| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | Library panels' lists (swatch, Color Book, brush, symbol and graphic style libraries) fill a taller panel above its footer (#2992; scores and estimates unchanged); freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | | Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (nine generated in code including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | From 302586960c45bbb5c846fdd8e74edb14b1eae1f9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 19:32:47 -0800 Subject: [PATCH 11/17] Colorize: a fill color recolors a colorized image whose link is missing A colorized image whose linked file is missing shows the placeholder preview, whose pixels are not grayscale. paint.setFill skipped such an image and added a fill item instead, while the Fill proxy showed the image's colorize color. A solid color now replaces the colorize color. While that color is a spot color, black or white, a process color is refused, since the relinked pixels may have an alpha channel. --- crates/engine/src/cmd/paint.rs | 19 +++++++++++-- crates/engine/src/tests/colorize.rs | 43 +++++++++++++++++++++++++++++ docs/mcp.md | 2 ++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 1aeba3ad30..cd04463749 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -690,7 +690,9 @@ fn placed_across(d: &Document, ids: &[NodeId], paint: Paint, p: &Value, fill: bo /// The images among `ids` that a fill of `paint` colorizes: grayscale and 1-bit images, for a solid /// color (or none on a colorized image, which takes the color off). The color may be black, white, /// a process color or a spot color; on an image with an alpha channel, only a spot color, black or -/// white, and other colors are an error. +/// white, and other colors are an error. A colorized image that shows a preview that isn't +/// grayscale, such as a missing link's placeholder, takes a solid color as its new colorize paint; +/// while its paint is one an alpha channel allows, a process color is an error there too. fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result::Result, String> { let mut out = vec![]; for id in ids { @@ -701,6 +703,19 @@ fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result: out.push(*id); continue; } + // The Fill proxy shows such an image's colorize paint. Its pixels are not known until the + // file is relinked: a process color replaces only a process color, which the image's + // pixels already accepted, so a relink can't bring back an alpha channel under one. + if matches!(paint, Paint::Solid { .. }) + && let Some(old) = n.extras().colorize.as_deref() + && d.image_blob(im).is_some_and(|b| !b.takes_colorize()) + { + if !colorizes_transparency(d, paint) && colorizes_transparency(d, old) { + return Err(PROCESS_ON_TRANSPARENCY.into()); + } + out.push(*id); + continue; + } let Some(gray) = d.image_blob(im).and_then(|b| b.gray_source()) else { continue }; match paint { // Without a color, `none` goes to the image's fill item. diff --git a/crates/engine/src/tests/colorize.rs b/crates/engine/src/tests/colorize.rs index c71014eb1c..e281b3ee04 100644 --- a/crates/engine/src/tests/colorize.rs +++ b/crates/engine/src/tests/colorize.rs @@ -462,6 +462,49 @@ fn a_colorized_missing_link_placeholder_is_not_flattened() { assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(255, 0, 0))), "the image keeps its paint"); } +/// A fill color on a colorized image that shows the missing-link placeholder recolors the image, +/// as the Fill proxy shows its colorize paint, and adds no fill item. +#[test] +fn a_fill_color_recolors_a_colorized_missing_link_placeholder() { + let mut s = session(); + let bytes = std::sync::Arc::new(vectorcraft_doc::links::missing_placeholder().unwrap()); + let id = image(&mut s, "pic", ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) }); + s.edit("Colorize", |d, _| { + d.node_mut(id).unwrap().extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0)))); + Ok(()) + }) + .unwrap(); + let appearance = s.doc().unwrap().doc.node(id).unwrap().appearance.clone(); + s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [id.0]})).unwrap(); + assert_eq!(colorize(&s, id).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#0000ff".into())); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance, appearance, "no fill item"); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#0000ff".into())); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(255, 0, 0)))); +} + +/// The pixels behind the placeholder may have an alpha channel, so a missing-link image colorized +/// with a spot color takes another spot color, black or white, and refuses a process color. +#[test] +fn a_colorized_missing_link_placeholder_in_a_spot_color_refuses_a_process_color() { + let mut s = session(); + let bytes = std::sync::Arc::new(vectorcraft_doc::links::missing_placeholder().unwrap()); + let id = image(&mut s, "pic", ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) }); + s.execute("swatch.new", &json!({"name": "Ink 2", "color": {"c": 100, "m": 0, "y": 0, "k": 0}, "spot": true})).unwrap(); + s.edit("Colorize", |d, _| { + let ink = Paint::Solid { color: Color::rgb8(255, 0, 0), swatch: Some("Ink".into()), tint: 1.0 }; + d.node_mut(id).unwrap().extras_mut().colorize = Some(Box::new(ink)); + Ok(()) + }) + .unwrap(); + let before = colorize(&s, id); + let e = s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [id.0]})).unwrap_err().to_string(); + assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!(colorize(&s, id), before); + s.execute("paint.setFill", &json!({"swatch": "Ink 2", "ids": [id.0]})).unwrap(); + assert!(matches!(colorize(&s, id), Some(Paint::Solid { swatch: Some(w), .. }) if w == "Ink 2"), "{:?}", colorize(&s, id)); +} + #[test] fn flattened_colorized_image_shows_the_art_under_white_pixels() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index 207ca19d57..bcbc26d73e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -578,6 +578,8 @@ ramp runs across them. `paint.setFill` without `item` on a grayscale or 1-bit image sets the color the image is drawn in: each pixel takes the color as far as the pixel is dark, and white pixels are transparent. `none` removes the color. An image with an alpha channel takes only a spot color, black or white; a process color is refused. +A colorized image whose link is missing shows the placeholder, and a color set on it replaces its colorize color. A +process color is refused there when the image is colorized with a spot color, black or white. For `draw_shape` and `draw_path`, omitted paint fields inherit the current [new-art appearance](#new-art). For a filled shape or path without an outline, pass an explicit `fill` and `stroke: "none"`. Lines start with no fill and a visible stroke. From 3bf00207a642b1fe342ab1b40ed40c8814146fdc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 19:41:57 -0800 Subject: [PATCH 12/17] Actions: action set files with Select Path steps are version 5 Version 4 marks sets with Select Object steps, so a build that reads version 4 but has no select.storedPath accepted a Select Path set and failed at the step during playback. Sets with Select Path steps now save as version 5, Load Actions reads versions 1 to 5, and docs/mcp.md and the file header say so. --- crates/ui-egui/src/panels/actions.rs | 13 ++++++++----- crates/ui-egui/src/panels/actions/files.rs | 21 +++++++++++---------- docs/mcp.md | 15 ++++++++------- docs/target-app-parity.md | 2 +- 4 files changed, 28 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 6cf62385c9..8196ad27c2 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -3184,7 +3184,7 @@ mod tests { (json!(long.to_string()), "an action in the action set file has more than 10,000 steps"), (json!(" ".repeat(files::MAX_SET_FILE + 1)), "the action set file is larger than 8 MiB"), ( - json!("{\"format\": \"vcactions\", \"version\": 5, \"name\": \"S\", \"actions\": []}"), + json!("{\"format\": \"vcactions\", \"version\": 6, \"name\": \"S\", \"actions\": []}"), "the action set file needs a later version of VectorCraft", ), ] { @@ -4693,19 +4693,22 @@ mod tests { let (mut app, _, _) = app_with_two_rectangles(); app.run(INSERT_SELECT_PATH, json!({"set": 0, "action": 0, "step": 0})).unwrap(); let saved = files::write_set(&app.ui.action_sets[0]); - assert_eq!(serde_json::from_str::(&saved).unwrap()["version"], json!(4), "older builds must refuse a Select Path step"); + assert_eq!(serde_json::from_str::(&saved).unwrap()["version"], json!(5), "older builds must refuse a Select Path step"); let loaded = files::read_set(&saved, "Test").unwrap(); assert_eq!(loaded.actions[0].steps, app.ui.action_sets[0].actions[0].steps); let step = app.ui.action_sets[0].actions[0].steps[1].clone(); let nested = json!({"commands": [{"command": SELECT_PATH_STEP, "params": step.1}]}); let set = ActionSet::new("Nested", vec![Action::new("A", vec![("command.batch".into(), nested)])]); - assert_eq!(serde_json::from_str::(&files::write_set(&set)).unwrap()["version"], json!(4), "a step in a recorded batch counts"); + assert_eq!(serde_json::from_str::(&files::write_set(&set)).unwrap()["version"], json!(5), "a step in a recorded batch counts"); + let mut both = app.ui.action_sets[0].clone(); + both.actions[0].steps.push((SELECT_OBJECT_STEP.into(), json!({"note": "a", "caseSensitive": false, "wholeWord": false}))); + assert_eq!(serde_json::from_str::(&files::write_set(&both)).unwrap()["version"], json!(5), "with a Select Object step too"); for params in [json!({"id": 3}), json!({"id": -1, "fingerprint": step.1["fingerprint"]}), json!({"id": 3, "fingerprint": "v1:xyz"}), json!(42)] { let bad = json!({"command": SELECT_PATH_STEP, "params": params}); for s in [bad.clone(), json!({"command": "command.batch", "params": {"commands": [bad]}})] { - let file = json!({"format": "vcactions", "version": 4, "name": "Bad", "actions": [{"name": "A", "steps": [s]}]}); + let file = json!({"format": "vcactions", "version": 5, "name": "Bad", "actions": [{"name": "A", "steps": [s]}]}); assert_eq!(files::read_set(&file.to_string(), "Test").unwrap_err(), "not an action set file", "{s}"); } } @@ -5786,7 +5789,7 @@ mod tests { json!({"format": "vcactions", "version": version, "name": "Old", "actions": [{"name": "A", "steps": [{"command": "object.move"}]}]}); assert_eq!(files::read_set(&old.to_string(), "Test").unwrap().actions[0].steps.len(), 1, "version {version}"); } - let later = json!({"format": "vcactions", "version": 5, "name": "New", "actions": []}); + let later = json!({"format": "vcactions", "version": 6, "name": "New", "actions": []}); assert_eq!(files::read_set(&later.to_string(), "Test").unwrap_err(), "the action set file needs a later version of VectorCraft"); for params in [json!({"message": 5}), json!({"allowContinue": "yes"}), json!(42)] { let stop = json!({"command": "action.stop", "params": params}); diff --git a/crates/ui-egui/src/panels/actions/files.rs b/crates/ui-egui/src/panels/actions/files.rs index 14f7717e64..6aa8b95df4 100644 --- a/crates/ui-egui/src/panels/actions/files.rs +++ b/crates/ui-egui/src/panels/actions/files.rs @@ -1,6 +1,6 @@ //! Action set files: the Actions panel menu's Save Actions… and Load Actions…. //! -//! An action set file (`.vcactions`) is JSON: `{"format": "vcactions", "version": 1 to 4, "name", +//! An action set file (`.vcactions`) is JSON: `{"format": "vcactions", "version": 1 to 5, "name", //! "actions": [{"name", "steps": [{"command", "params", "excluded"?}], "key"?, "color"?}]}`. Each step is a //! command id and its params, as `command.batch` takes them, or a stop: the command `action.stop` //! with `{"message", "allowContinue"}`. A Select Object step is the command `select.byNote` with @@ -10,9 +10,9 @@ //! or with a value that is not one, has none. `excluded: true` keeps a step but skips it during //! playback. Sets with exclusions use version 2 so older builds refuse them rather than replay //! excluded steps, sets with stops use version 3 so older builds refuse them rather than fail at a -//! stop, and sets with Select Object or Select Path steps use version 4 so older builds refuse them -//! rather than fail at that step. Other sets use version 1, and earlier version 1 to 3 files still -//! work. +//! stop, sets with Select Object steps use version 4 and sets with Select Path steps use version 5 so +//! older builds refuse them rather than fail at that step. Other sets use version 1, and earlier +//! version 1 to 4 files still work. //! Load Actions… also reads the action sets another app saves as text (`.aia`, through //! `actionSet.read`). @@ -173,11 +173,12 @@ pub fn write_set(set: &ActionSet) -> String { let has_select_object = steps().any(|(command, params)| !select_objects_in(command, params).is_empty()); let has_select_path = steps().any(|(command, params)| !select_paths_in(command, params).is_empty()); let has_stop = steps().any(|(command, params)| !stops_in(command, params).is_empty()); - let version = match (has_select_object || has_select_path, has_stop, set.actions.iter().any(|a| !a.excluded_steps.is_empty())) { - (true, ..) => 4, - (false, true, _) => 3, - (false, false, true) => 2, - (false, false, false) => 1, + let version = match (has_select_path, has_select_object, has_stop, set.actions.iter().any(|a| !a.excluded_steps.is_empty())) { + (true, ..) => 5, + (false, true, ..) => 4, + (false, false, true, _) => 3, + (false, false, false, true) => 2, + (false, false, false, false) => 1, }; let file = SetFile { format: ACTIONS_EXT.into(), version, name: set.name.clone(), actions }; serde_json::to_string_pretty(&file).unwrap_or_default() @@ -192,7 +193,7 @@ pub fn read_set(text: &str, name: &str) -> Result { if f.format != ACTIONS_EXT { return Err("not an action set file".into()); } - if !matches!(f.version, 1..=4) { + if !matches!(f.version, 1..=5) { return Err("the action set file needs a later version of VectorCraft".into()); } if f.actions.len() > MAX_SET_ACTIONS { diff --git a/docs/mcp.md b/docs/mcp.md index 15fa2b061b..1e0571c3c2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2987,14 +2987,15 @@ already walks Image Trace. Short agent entry points: the file at `path`, else a picked one, or the file's text `data`, adds its set after the other sets and returns `{set, name, actions}`. A set named like one already in the panel gets the next free number (`Print 2`), and the other sets and the current set stay as they are. An action set file (`.vcactions`) is JSON: - `{"format": "vcactions", "version": 1 to 4, "name", "actions": [{"name", "steps": [{"command", "params", "excluded"?}]}]}`, each step + `{"format": "vcactions", "version": 1 to 5, "name", "actions": [{"name", "steps": [{"command", "params", "excluded"?}]}]}`, each step a command id and its params as `command.batch` takes them, or a stop: the command `action.stop` with `{message, allowContinue}`. A Select Object step is the command `select.byNote` with `{note, caseSensitive, - wholeWord}`. `excluded: true` preserves a step but omits it from playback; - an absent or false flag includes the step. Files with Select Object steps use version 4, files with stops and no - Select Object step use version 3, and files with excluded steps and neither use version 2, so older builds refuse them - instead of playing the excluded steps or failing at a stop or a Select Object step; other files use version 1. A file with another `format` or a `version` other than - 1 to 4, a stop whose message is not text or whose `allowContinue` is not true or false, a Select Object step whose params `ui.action.insertSelectObject` would refuse, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are + wholeWord}`, and a Select Path step is the command `select.storedPath` with `{id, fingerprint}`. `excluded: true` preserves a step but omits it from playback; + an absent or false flag includes the step. Files with Select Path steps use version 5, files with Select Object steps + and no Select Path step use version 4, files with stops and neither use version 3, and files with excluded steps and + none of these use version 2, so older builds refuse them instead of playing the excluded steps or failing at a stop, a + Select Object step or a Select Path step; other files use version 1, and version 1 to 4 files still load. A file with another `format` or a `version` other than + 1 to 5, a stop whose message is not text or whose `allowContinue` is not true or false, a Select Object step whose params `ui.action.insertSelectObject` would refuse, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are refused, and no set is added. So is a file whose set `ui.actionSet.save` would write larger than 8 MiB (a file with less white space, a set name that gets a number, or an `.aia` set), with the error `the action set would be larger than 8 MiB`. The Actions panel menu's Save Actions… and Load Actions… run these; Load Actions… is @@ -3097,7 +3098,7 @@ already walks Image Trace. Short agent entry points: index past the last set, action or step, no action and no recording, or a step past the 10,000-step and 8 MiB limits is an error, and then nothing changes. The panel menu's Insert Select Path runs this for the selected step or action, or the recording in progress, and is off while the selection is not such a path. The step's row in the panel reads - Select Path. An action set file holding the step is version 4, and Load Actions… refuses a step whose params are not + Select Path. An action set file holding the step is version 5, and Load Actions… refuses a step whose params are not an id and such a fingerprint. - Actions: `select.storedPath {id, fingerprint}` selects only the path `id` while its fingerprint is still `fingerprint` and it can be selected: not hidden or locked, nor in a hidden or locked layer or group, a template diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9d3107a2ae..57df2225e6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -743,7 +743,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel: the panel menu's Playback Options… sets how actions play: Accelerated (the default) plays every step at once, Step by Step plays one step at a time with the canvas drawn after each, and Pause For waits the given number of seconds between steps ([Specify playback speed](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/specify-playback-speed.html); `ui.action.playbackOptions`). VectorCraft's own rules, which the page does not cover: the pause is a whole number of seconds from 1 to 60, the setting is kept with the UI preferences, a paced playback ends at Stop Playing/Recording or when a step comes due while another document is active, each step of a paced playback is its own undo step, and Batch… plays at full speed. - Actions panel: the panel menu's Insert Stop… opens Record Stop, with a Message box and Allow Continue, and adds a stop to the recording in progress, after the selected command, or at the end of the selected action ([Insert stops in actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-stops-in-actions.html); `ui.action.insertStop`). A playing action waits at the stop and shows the message so the user can do a manual task, and Play Current Selection plays the rest ([Illustrator CS5 help](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=459); `ui.action.continue`). With Allow Continue, the message box also has Continue. VectorCraft's own rules, which the pages do not cover: an empty message still waits, Stop Playing/Recording ends the playback and keeps the steps already played, the rest plays on the document active then, a stop waits at every playback speed, at Accelerated the steps between two stops are one undo step, Batch… refuses an action with an included stop, and Delete Selection on a selected step deletes that step, a stop or a command, without asking (`ui.action.deleteStep`). - Actions panel: the panel menu's Insert Menu Item… adds a menu command to the recording in progress, after the selected step, or at the end of the selected action, without running it, and playback runs it as the menu does ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); `ui.action.insertMenuItem`). The dialog's Find box picks the one item that matches the words typed, and a click on a listed item picks it. VectorCraft's own rules, which the page does not cover: items that open a dialog, the Object › Align items, Image Trace's Make and Make and Expand, Pattern's Make and Edit Pattern, and the File, Window and Help menu items are not listed, and an action holds at most 10,000 steps and its set file at most 8 MiB, for stops too. - - Actions panel: with a path selected, the panel menu's Insert Select Path adds a step that selects that path to the recording in progress, after the selected step, or at the end of the selected action, without changing the selection ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); `ui.action.insertSelectPath`, `select.storedPath`). VectorCraft's own rules, which the page does not cover: the selection must be one whole path or compound path that can be selected, the step stores the path's id and a fingerprint of its anchors, and playback selects the path only while it is still there unchanged and can be selected, else the step is an error and the selection stays; the row reads Select Path, and a set file with the step is version 4. + - Actions panel: with a path selected, the panel menu's Insert Select Path adds a step that selects that path to the recording in progress, after the selected step, or at the end of the selected action, without changing the selection ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); `ui.action.insertSelectPath`, `select.storedPath`). VectorCraft's own rules, which the page does not cover: the selection must be one whole path or compound path that can be selected, the step stores the path's id and a fingerprint of its anchors, and playback selects the path only while it is still there unchanged and can be selected, else the step is an error and the selection stays; the row reads Select Path, and a set file with the step is version 5. - Actions panel: the panel menu's Select Object… opens Set Selection, where the note typed names the objects to select, and adds a step to the recording in progress, after the selected step, or at the end of the selected action; playback selects the objects whose Attributes note matches it, and the steps after it act on them ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); `ui.action.insertSelectObject`, `select.byNote`). VectorCraft's own rules, which the page does not cover: the note matches anywhere and in any letter case unless Case Sensitive or Whole Word is checked, hidden and locked objects are left out, the objects found replace the selection, a step that finds none stops the playback with an error, and the note is at most 1,024 bytes. Action sets with the step save as version 4. - Actions panel: Delete Selection deletes the selected action set with its actions and keeps the other sets ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.delete`). VectorCraft's own rules, which the overview does not cover: deleting a set asks first, Shift-click on a set's name selects a range of sets to delete together, the panel menu's Delete does the same, and Default Actions and the last set can be deleted. Undo does not bring a deleted set back. - Actions panel: the panel menu's Duplicate copies the selected action, else the current action set with all its actions ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.duplicate`). VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected. From b9b960d7170ef2167c6ff43a00421f28be1771f7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 19:52:05 -0800 Subject: [PATCH 13/17] Fonts: Find Font skips the patterns of inline graphics without decorations The characters of an inline graphic draw no glyph, so their fill and stroke paint only their underline and strikethrough (not on a path or in vertical type) and the ruby readings that layout draws with them (a reading takes the paint of the first base character on its line, and type on a path has no ruby). Without one, their pattern no longer counts as painted for Find, selection-only Change and Print. --- crates/engine/src/cmd/fonts.rs | 114 +++++++++++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 3119887ad3..005427ded8 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -127,21 +127,53 @@ fn draws_inline(r: &TextRun) -> bool { r.inline.is_some() && vectorcraft_text::edit::is_inline_text(&r.text) } -/// The fill and, when it is wider than 0, the stroke of the characters of `r` (of an inline -/// graphic too: they paint its underline, strikethrough and ruby). +/// The fill and, when it is wider than 0, the stroke of the characters of `r`. fn run_paints(r: &TextRun) -> impl Iterator { [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)].into_iter().flatten() } +/// [`run_paints`] of run `r` of `t` when its characters draw with them. The characters of an +/// inline graphic draw no glyph: their fill and stroke paint only their underline and +/// strikethrough (not on a path or in vertical type, see `vectorcraft_text::decorations`) and the +/// ruby readings that layout draws with their run (`ruby`, as [`ruby_runs`] finds them). +fn drawn_run_paints<'a>(t: &TextObject, ruby: &HashSet<*const TextRun>, r: &'a TextRun) -> impl Iterator { + let drawn = !draws_inline(r) || inline_decorated(t, ruby, r); + run_paints(r).filter(move |_| drawn) +} + +/// The inline graphic runs of `t` that layout draws a ruby reading with. A reading takes the run +/// of the first base character on its line, and type on a path has no ruby. `t` is laid out only +/// when it has ruby and an inline graphic painted with a pattern. +fn ruby_runs(t: &TextObject) -> HashSet<*const TextRun> { + let patterned = |r: &TextRun| draws_inline(r) && run_paints(r).any(|p| pattern_name(p).is_some()); + if t.ruby.is_empty() || !t.runs.iter().any(patterned) { + return HashSet::new(); + } + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + lay.ruby.iter().filter_map(|g| t.runs.get(g.run)).filter(|r| draws_inline(r)).map(std::ptr::from_ref).collect() +} + +/// Whether inline graphic run `r` of `t` has an underline or strikethrough that draws, or a ruby +/// reading that layout draws with it (it is in `ruby`). +fn inline_decorated(t: &TextObject, ruby: &HashSet<*const TextRun>, r: &TextRun) -> bool { + let bars = !t.vertical && !matches!(t.kind, TextKind::OnPath { .. }); + if bars && (r.style.underline || r.style.strikethrough) { + return true; + } + ruby.contains(&std::ptr::from_ref(r)) +} + /// The patterns that the fills and strokes `n` draws name (of a text object, also those of its /// characters): those that [`paints`] holds for, and the character paints of the runs that draw -/// (as [`drawn_runs`] finds them: not the overset runs of area type or type on a path). +/// (as [`drawn_runs`] finds them: not the overset runs of area type or type on a path) where they +/// draw (as [`drawn_run_paints`] says). fn paint_patterns(n: &Node) -> Vec<&str> { let mut out: Vec<&str> = n.appearance.items.iter().filter(|i| paints(i)).filter_map(|i| pattern_name(i.paint())).collect(); if let NodeKind::Text(t) = &n.kind && t.runs.iter().flat_map(run_paints).any(|p| pattern_name(p).is_some()) { - out.extend(drawn_runs(t).0.into_iter().flat_map(run_paints).filter_map(pattern_name)); + let ruby = ruby_runs(t); + out.extend(drawn_runs(t).0.into_iter().flat_map(|r| drawn_run_paints(t, &ruby, r)).filter_map(pattern_name)); } out } @@ -519,7 +551,15 @@ impl Printed<'_> { _ => (vec![], false), }; let items: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| paints(i)).collect(); - let patterns: Vec<&str> = items.iter().map(|i| i.paint()).chain(runs.iter().flat_map(|r| run_paints(r))).filter_map(pattern_name).collect(); + let ruby = match &n.kind { + NodeKind::Text(t) => ruby_runs(t), + _ => HashSet::new(), + }; + let run_patterns = runs.iter().flat_map(|r| match &n.kind { + NodeKind::Text(t) => Some(drawn_run_paints(t, &ruby, r)), + _ => None, + }); + let patterns: Vec<&str> = items.iter().map(|i| i.paint()).chain(run_patterns.flatten()).filter_map(pattern_name).collect(); let brushed = vectorcraft_brush::has_brush(n); if (mask.is_some() || !patterns.is_empty() || brushed) && areas.is_none_or(|a| !open || self.meets(n, a)) { if let Some(m) = mask { @@ -1939,6 +1979,70 @@ mod tests { assert_eq!(s.doc().unwrap().selection.objects, [NodeId(point)]); } + /// The characters of an inline graphic draw no glyph: their pattern fill paints only their + /// underline, strikethrough or ruby reading. Without one, Find doesn't select the text for the + /// pattern's type and Print doesn't count its font. + #[test] + fn find_font_skips_patterns_of_undecorated_inline_graphics() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let mark = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [mark]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let point = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Hi", "font": "Source Sans 3"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": point, "at": 1, "symbol": "Mark"})).unwrap(); + let style = |s: &mut Session, f: &dyn Fn(&mut TextObject)| { + s.edit("Style the inline graphic", |d, _| { + if let Some(NodeKind::Text(t)) = d.node_mut(NodeId(point)).map(|n| &mut n.kind) { + f(t); + } + Ok(()) + }) + .unwrap(); + }; + let found = |s: &mut Session| s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"].as_u64(); + let printed = |s: &Session| { + let d = &s.doc().unwrap().doc; + printed_type(d, &d.layers, &[Rect::new(-1000.0, -1000.0, 1000.0, 1000.0)]).0.into_iter().map(|(family, ..)| family).collect::>() + }; + style(&mut s, &|t| { + for r in t.runs.iter_mut().filter(|r| r.inline.is_some()) { + r.style.fill = Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }; + } + }); + assert_eq!(found(&mut s), Some(0)); + assert_eq!(printed(&s), ["Source Sans 3"]); + // An underline paints with the pattern. + style(&mut s, &|t| t.runs.iter_mut().filter(|r| r.inline.is_some()).for_each(|r| r.style.underline = true)); + assert_eq!(found(&mut s), Some(1)); + assert_eq!(printed(&s), ["Source Sans 3", "Source Serif 4"]); + // Vertical type draws no underline. + style(&mut s, &|t| t.vertical = true); + assert_eq!(found(&mut s), Some(0)); + // A ruby reading that starts over the character before the inline graphic draws with that + // character's paint. + style(&mut s, &|t| { + assert!(t.runs[1].inline.is_some(), "the inline graphic is the second run"); + t.ruby.push(vectorcraft_doc::RubySpan { + start: 0, + end: t.runs[0].text.len() + t.runs[1].text.len(), + text: "x".into(), + position: Default::default(), + align: Default::default(), + size_percent: 50.0, + overhang: true, + }); + }); + assert_eq!(found(&mut s), Some(0)); + // A ruby reading that starts over the inline graphic paints with the pattern. + style(&mut s, &|t| t.ruby[0].start = t.runs[0].text.len()); + assert_eq!(found(&mut s), Some(1)); + } + /// Text whose inline graphic draws a symbol shows the symbol's type: Find selects that text /// and the objects painted with a pattern whose art holds it, and a selection-only Change /// changes the symbol's type. From b89ddeccd0a121442030ce9642ee52e5d81eba15 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 19:59:19 -0800 Subject: [PATCH 14/17] Smart Guides: a moved selection reaches ruler guides within the Snapping Tolerance With Smart Guides on, a selection moved with the Selection tool, Direct Selection or a shape tool reached a ruler guide only within the Snap to Point distance (2 px), while drawn points and bounding-box handles reach it within the Snapping Tolerance (4 px). The move now uses the Snapping Tolerance for the grabbed point and the edges, with and without Shift. With Smart Guides off, Snap to Point keeps its own distance. --- crates/tools/src/select.rs | 9 ++++---- crates/tools/src/select/tests.rs | 35 +++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 17bf7eeaef..d0d624bbeb 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -239,7 +239,8 @@ pub(crate) struct MoveSnap { copy_targets: Option, copy_points: Option, /// With Smart Guides on, the ruler guides View › Snap to Point still pulls the grabbed point - /// onto (#1534), and lines the bounds up with (#5746); `points` holds them with Smart Guides off. + /// onto (#1534), and lines the bounds up with (#5746), within the Snapping Tolerance as drawing + /// does; `points` holds them with Smart Guides off. rulers: Option, } @@ -303,7 +304,7 @@ impl MoveSnap { // axis without one that Smart Guides left free, the bounds line up with the ruler guides // (#5746), alongside the Smart Guides. if let Some(t) = &self.rulers { - let (adj, ov, held) = t.snap_moved_to_rulers(start + d, self.bounds.map(|b| b + d), cx.tol(cx.snap_tolerance), taken); + let (adj, ov, held) = t.snap_moved_to_rulers(start + d, self.bounds.map(|b| b + d), cx.snap_tol(), taken); if adj != Vec2::ZERO { d += adj; if held { @@ -373,7 +374,7 @@ impl MoveSnap { } } if let Some(t) = &self.rulers { - let (q, ov) = t.snap_point_along(start + d, u, cx.tol(cx.snap_tolerance)); + let (q, ov) = t.snap_point_along(start + d, u, cx.snap_tol()); if q != start + d { d = q - start; guides = ov; @@ -382,7 +383,7 @@ impl MoveSnap { { // Nothing else lined up: the bounds slide along the line onto a ruler guide, as // they do without Shift (#4312). - let (adj, ov) = t.snap_moved_along_to_rulers(start + d, b + d, u, cx.tol(cx.snap_tolerance)); + let (adj, ov) = t.snap_moved_along_to_rulers(start + d, b + d, u, cx.snap_tol()); d += adj; guides = ov; } diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 659e50a6a5..c41056b7aa 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -751,7 +751,7 @@ fn escape_cancels_a_move_or_an_alt_drag_copy() { } /// #1534: with Smart Guides on (the default), the point a selection is dragged by still snaps -/// to a ruler guide within the Snap to Point distance; not with Snap to Point off. +/// to a ruler guide within the Snapping Tolerance; not with Snap to Point off. #[test] fn a_move_snaps_the_grabbed_point_to_a_ruler_guide_with_smart_guides_on() { let (mut d, id) = doc_with_rect(); @@ -797,6 +797,39 @@ fn a_shift_move_lands_the_edges_on_a_ruler_guide() { assert!(moved_by(m, (231.0, 0.0)), "{m:?}"); } +/// With Smart Guides on, a moved selection reaches a ruler guide within the Snapping Tolerance +/// (4 px), as drawing and handle drags do; with Smart Guides off, Snap to Point reaches it within +/// its own distance (2 px). +#[test] +fn a_move_reaches_ruler_guides_within_the_snapping_tolerance() { + let (mut d, id) = doc_with_rect(); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 340.0)); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let dx = |c: &ToolContext, to: f64| { + let mut t = SelectionTool::default(); + t.pointer(c, &ev(PointerKind::Down, 150.0, 150.0)); + let a = t.pointer(c, &ev(PointerKind::Drag, to, 150.0)); + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + v["matrix"][4].as_f64().unwrap() + }; + let c = cx(&d, &s, &p); + assert!(c.smart_guides && c.snap_to_point && c.guides); + // The right edge, 3 px short of the guide, lands on it, with or without Shift. + assert_eq!(dx(&c, 150.0 + 137.0), 140.0); + let (m, _) = shift_moved(&c, (150.0 + 137.0, 150.0)); + assert!(moved_by(m, (140.0, 0.0)), "{m:?}"); + // The grabbed point, 3 px from the guide, lands on it. + assert_eq!(dx(&c, 337.0), 190.0); + // 5 px away is out of reach. + assert_eq!(dx(&c, 150.0 + 135.0), 135.0); + // Smart Guides off: the grabbed point lands on the guide from 1.5 px, not from 3 px. + let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) }; + assert_eq!(dx(&off, 338.5), 190.0); + assert_eq!(dx(&off, 337.0), 187.0); +} + /// Alt pressed during a move makes a copy, which snaps to the original it leaves behind (as /// gathered before the previews moved it); released, the move doesn't. #[test] From 9dc60090073f335dc641fdb6467ce30718dae4cc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 20:12:14 -0800 Subject: [PATCH 15/17] i18n: 3D effects' refusal messages are translated The refusals of Extrude & Bevel, Revolve, Inflate, Rotate and their Classic forms (art a 3D effect can't build a solid from, invalid light, shadow, graphics and surface settings, and Expand as Wireframes notes) now have an @msg row in every complete-message catalog, as do the effect names and object kinds the messages name, so the status bar shows them in the interface language. Parameter keys, numbers and names keep their English form; command replies stay English. A value a message quotes in "...", curly quotes or backticks, such as a symbol's name, is shown as written instead of being looked up as a message of its own. --- crates/ui-egui/src/i18n.rs | 6 +- crates/ui-egui/src/i18n/ca.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/cs.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/de.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/el.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/es.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/fi.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/fr.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/hi.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/hu.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/it.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/ja.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/ka.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/ko.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/pl.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/pt-br.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/ru.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/sv.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/tests.rs | 151 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/tr.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/uk.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/vi.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/zh-hans.tsv | 59 +++++++++++ crates/ui-egui/src/i18n/zh-hant.tsv | 59 +++++++++++ docs/target-app-parity.md | 1 + 25 files changed, 1454 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 7ee09ee14e..8ffd5a8cef 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -659,7 +659,8 @@ const MESSAGE_DEPTH: usize = 5; /// Translate a status or error message for display (see the module docs). Unknown messages, and /// parts of them (file names, numbers, a reason with no entry, a JSON object or array such as -/// `{"objects": [...]}`), stay as they are. +/// `{"objects": [...]}`, a value the message quotes in `"…"`, `“…”` or backticks, such as a +/// symbol's name), stay as they are. pub fn message(lang: Lang, s: &str) -> String { message_at(lang, s, MESSAGE_DEPTH) } @@ -669,7 +670,8 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String { let count = caps.iter().find(|(k, _)| *k == "n").and_then(|(_, v)| v.parse::().ok()); let form = count.map_or(forms.len().saturating_sub(1), |n| (lang.0.plural)(n).min(forms.len().saturating_sub(1))); let Some(template) = forms.get(form) else { return s.to_string() }; - let nested = |v: &str| depth > 1 && !v.starts_with(['{', '[']); + let quoted = |v: &str| [('"', '"'), ('“', '”'), ('`', '`')].iter().any(|(a, b)| s.contains(&format!("{a}{v}{b}"))); + let nested = |v: &str| depth > 1 && !v.starts_with(['{', '[']) && !quoted(v); let values: Vec<(&str, String)> = caps.into_iter().map(|(k, v)| (k, if nested(v) { message_at(lang, v, depth - 1) } else { v.to_string() })).collect(); let args: Vec<(&str, &str)> = values.iter().map(|(k, v)| (*k, v.as_str())).collect(); diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 33df72c253..2e0a536b8d 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6026,6 +6026,65 @@ digits Malayalam Malaiàlam @msg Targa files are RGB: colorModel rgb Els fitxers Targa són RGB: colorModel rgb @msg Text Wrap: select a wrap object Ajustament de text: selecciona un objecte d’ajustament @msg that document is no longer open aquest document ja no està obert +@msg Extrude & Bevel Extrudeix i bisella +@msg Revolve Revoluciona +@msg Inflate Infla +@msg Rotate Gira +@msg Extrude & Bevel (Classic) Extrudeix i bisella (clàssic) +@msg Revolve (Classic) Revoluciona (clàssic) +@msg Rotate (Classic) Gira (clàssic) +@msg This effect Aquest efecte +@msg clipping paths traçats de retall +@msg other objects altres objectes +@msg {label} requires a group holding a shape with at least two anchors {label} requereix un grup que contingui una forma amb com a mínim dos punts d’ancoratge +@msg Revolve requires a group holding a shape with at least two anchors Revoluciona requereix un grup que contingui una forma amb com a mínim dos punts d’ancoratge +@msg Revolve requires a shape with at least two anchors Revoluciona requereix una forma amb com a mínim dos punts d’ancoratge +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} en un grup necessita traçats, traçats compostos, text, grups, grups de retall, fusions i instàncies de símbol: primer allibera o expandeix aquests elements: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} en un grup admet com a màxim {MAX_MEMBERS} objectes +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Un efecte 3D en un grup o una capa s’aplica al mateix grup, no al seu farciment ni al seu traç +@msg a 3D effect on a group's fill or stroke draws nothing un efecte 3D al farciment o al traç d’un grup no dibuixa res +@msg a 3D effect on a group's fill or stroke draws nothing to export un efecte 3D al farciment o al traç d’un grup no dibuixa res per exportar +@msg The 3D object is too complex to export L’objecte 3D és massa complex per exportar-lo +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) l’objecte no té cap efecte 3D (Extrudeix i bisella, Revoluciona, Infla o Gira) +@msg its 3D effect builds no solid el seu efecte 3D no construeix cap sòlid +@msg bevel must be none or one of {_1} bevel ha de ser none o un de {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} no pot construir sòlids a partir d’aquesta il·lustració: les seves instàncies de símbol, fusions i grups de retall contenen massa objectes +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} no pot construir un sòlid a partir de la instància del símbol «{symbol}»: la il·lustració del símbol no està disponible +@msg {_1}: the clipping path is too complex to cut the art with {_1}: el traçat de retall és massa complex per retallar-hi la il·lustració +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} admet com a màxim {MAX_MEMBERS} objectes +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} no pot construir sòlids a partir d’una il·lustració imbricada en més de {MAX_DEPTH} nivells +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient La pintura de Revoluciona és massa complexa per a aquesta superfície; redueix Segments o simplifica el degradat +@msg Revolve edge must be left or right Revoluciona: edge ha de ser left o right +@msg Revolve cap must be a boolean Revoluciona: cap ha de ser un booleà +@msg the revolved object has no fill or stroke l’objecte de revolució no té farciment ni traç +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor ha de ser un color RGB opac en format #rrggbb +@msg lights must contain one to ten lights lights ha de contenir d’un a deu llums +@msg each light must be an object cada llum ha de ser un objecte +@msg unknown light setting: use color, intensity, rotation, height, softness or behind paràmetre de llum desconegut: fes servir color, intensity, rotation, height, softness o behind +@msg light {key} must be a number from {min} to {max} llum: {key} ha de ser un nombre de {min} a {max} +@msg light color must use opaque #rrggbb color d’un llum ha de fer servir #rrggbb opac +@msg light behind must be true or false behind d’un llum ha de ser true o false +@msg shadow must be true or false shadow ha de ser true o false +@msg shadowPosition must be behind or below shadowPosition ha de ser behind o below +@msg {key} must be a number from {min} to {max} {key} ha de ser un nombre de {min} a {max} +@msg each of `{GRAPHICS}` must be an object cada element de `{GRAPHICS}` ha de ser un objecte +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` les entrades de `{GRAPHICS}` admeten symbol, face, x, y, scale i rotation, no `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol cada element de `{GRAPHICS}` necessita `symbol`, el nom d’un símbol +@msg `{GRAPHICS}` face must be front or back face de `{GRAPHICS}` ha de ser front o back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} de `{GRAPHICS}` ha de ser un nombre de {lo} a {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` ha de ser una matriu +@msg Graphics map onto planar faces: use the Extrude or Plane object type Els gràfics s’apliquen a cares planes: fes servir el tipus d’objecte Extrusió o Pla +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` admet com a màxim {MAX_GRAPHICS} gràfics +@msg A plane has one face: map its graphics onto the front Un pla té una sola cara: aplica’n els gràfics a la cara davantera +@msg The graphics are too complex: simplify the symbols' art Els gràfics són massa complexos: simplifica la il·lustració dels símbols +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art La il·lustració d’un gràfic es pot imbricar com a màxim en {MAX_LEVELS} nivells: simplifica la il·lustració del símbol +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art La il·lustració d’un gràfic pot imbricar símbols com a màxim en {MAX_NESTING} nivells: simplifica la il·lustració del símbol +@msg A graphic's art holds too many objects: simplify the symbol's art La il·lustració d’un gràfic conté massa objectes: simplifica la il·lustració del símbol +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Un gràfic dibuixa com a màxim {MAX_PIECES} farciments i traços: simplifica la il·lustració del símbol +@msg `surface` must be one of {_1} `surface` ha de ser un de {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} dibuixa la il·lustració d’origen d’aquest objecte en lloc de la seva superfície 3D +@msg {label} draws no faces for this object {label} no dibuixa cap cara per a aquest objecte +@msg the wireframe has more than {MAX_WIRE_EDGES} edges el filferro té més de {MAX_WIRE_EDGES} arestes @msg The 3D surface is too complex; simplify the shape La superfície 3D és massa complexa; simplifica la forma @msg the action has no steps l’acció no té cap pas @msg the action has no included steps l’acció no té cap pas inclòs diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ff48f1ecfc..3edca9e16c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5876,6 +5876,65 @@ mark-style Japanese Japonské @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} nedokáže vytvořit těleso z obrazu: nejprve jej vektorizujte příkazem Objekt › Vektorizace obrazu › Vytvořit. @msg {label} requires a shape with at least two anchors {label} vyžaduje tvar alespoň se dvěma kotevními body @msg The shape is too complex for a 3D effect Tvar je pro 3D efekt příliš složitý +@msg Extrude & Bevel Vysunutí a zkosení +@msg Revolve Rotační těleso +@msg Inflate Nafouknutí +@msg Rotate Otočit +@msg Extrude & Bevel (Classic) Vysunutí a zkosení (klasické) +@msg Revolve (Classic) Rotační těleso (klasické) +@msg Rotate (Classic) Otočit (klasické) +@msg This effect Tento efekt +@msg clipping paths ořezové cesty +@msg other objects jiné objekty +@msg {label} requires a group holding a shape with at least two anchors {label} vyžaduje skupinu obsahující tvar alespoň se dvěma kotevními body +@msg Revolve requires a group holding a shape with at least two anchors Rotační těleso vyžaduje skupinu obsahující tvar alespoň se dvěma kotevními body +@msg Revolve requires a shape with at least two anchors Rotační těleso vyžaduje tvar alespoň se dvěma kotevními body +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} na skupině potřebuje cesty, složené cesty, text, skupiny, ořezové skupiny, prolnutí a instance symbolů: nejprve uvolněte nebo rozdělte tyto položky: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} na skupině přijímá nejvýše {MAX_MEMBERS} objektů +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke 3D efekt na skupině nebo vrstvě patří na samotnou skupinu, ne na její výplň nebo tah +@msg a 3D effect on a group's fill or stroke draws nothing 3D efekt na výplni nebo tahu skupiny nic nekreslí +@msg a 3D effect on a group's fill or stroke draws nothing to export 3D efekt na výplni nebo tahu skupiny nekreslí nic k exportu +@msg The 3D object is too complex to export 3D objekt je pro export příliš složitý +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) objekt nemá žádný 3D efekt (Vysunutí a zkosení, Rotační těleso, Nafouknutí ani Otočit) +@msg its 3D effect builds no solid jeho 3D efekt nevytváří žádné těleso +@msg bevel must be none or one of {_1} bevel musí být none nebo jedno z {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} nemůže z této kresby vytvořit tělesa: její instance symbolů, prolnutí a ořezové skupiny obsahují příliš mnoho objektů +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} nemůže vytvořit těleso z instance symbolu „{symbol}“: kresba symbolu není k dispozici +@msg {_1}: the clipping path is too complex to cut the art with {_1}: ořezová cesta je příliš složitá na oříznutí kresby +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} přijímá nejvýše {MAX_MEMBERS} objektů +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} nemůže vytvořit tělesa z kresby vnořené hlouběji než do {MAX_DEPTH} úrovní +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Barva rotačního tělesa je pro tento povrch příliš složitá; snižte Segmenty nebo zjednodušte přechod +@msg Revolve edge must be left or right Rotační těleso: edge musí být left nebo right +@msg Revolve cap must be a boolean Rotační těleso: cap musí být logická hodnota +@msg the revolved object has no fill or stroke rotovaný objekt nemá výplň ani tah +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor musí být neprůhledná barva RGB ve tvaru #rrggbb +@msg lights must contain one to ten lights lights musí obsahovat jedno až deset světel +@msg each light must be an object každé světlo musí být objekt +@msg unknown light setting: use color, intensity, rotation, height, softness or behind neznámé nastavení světla: použijte color, intensity, rotation, height, softness nebo behind +@msg light {key} must be a number from {min} to {max} světlo: {key} musí být číslo od {min} do {max} +@msg light color must use opaque #rrggbb color světla musí být neprůhledná barva #rrggbb +@msg light behind must be true or false behind světla musí být true nebo false +@msg shadow must be true or false shadow musí být true nebo false +@msg shadowPosition must be behind or below shadowPosition musí být behind nebo below +@msg {key} must be a number from {min} to {max} {key} musí být číslo od {min} do {max} +@msg each of `{GRAPHICS}` must be an object každá položka `{GRAPHICS}` musí být objekt +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` položky `{GRAPHICS}` přijímají symbol, face, x, y, scale a rotation, ne `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol každá položka `{GRAPHICS}` potřebuje `symbol`, název symbolu +@msg `{GRAPHICS}` face must be front or back face v `{GRAPHICS}` musí být front nebo back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} v `{GRAPHICS}` musí být číslo od {lo} do {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` musí být pole +@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafika se mapuje na rovinné stěny: použijte typ objektu Vysunutí nebo Rovina +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` přijímá nejvýše {MAX_GRAPHICS} grafik +@msg A plane has one face: map its graphics onto the front Rovina má jednu stranu: umístěte její grafiku na přední stranu +@msg The graphics are too complex: simplify the symbols' art Grafika je příliš složitá: zjednodušte kresbu symbolů +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Kresba grafiky smí být vnořena nejvýše do {MAX_LEVELS} úrovní: zjednodušte kresbu symbolu +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Kresba grafiky smí vnořovat symboly nejvýše do {MAX_NESTING} úrovní: zjednodušte kresbu symbolu +@msg A graphic's art holds too many objects: simplify the symbol's art Kresba grafiky obsahuje příliš mnoho objektů: zjednodušte kresbu symbolu +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Grafika kreslí nejvýše {MAX_PIECES} výplní a tahů: zjednodušte kresbu symbolu +@msg `surface` must be one of {_1} `surface` musí být jedno z {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} pro tento objekt kreslí zdrojovou kresbu místo jeho 3D povrchu +@msg {label} draws no faces for this object {label} pro tento objekt nekreslí žádné stěny +@msg the wireframe has more than {MAX_WIRE_EDGES} edges drátový model má více než {MAX_WIRE_EDGES} hran @msg The 3D surface is too complex; simplify the shape 3D povrch je příliš složitý; zjednodušte tvar Transform Objects Transformovat objekty @msg unknown parameter `{key}`; did you mean `{near}`? neznámý parametr `{key}`; mysleli jste `{near}`? diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1d3b12f63a..bba8e53611 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5451,6 +5451,65 @@ lighting Light Licht @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} kann aus einem Bild keinen Körper erstellen: Zeichnen Sie es zuerst mit Objekt › Bildnachzeichner › Erstellen nach. @msg {label} requires a shape with at least two anchors {label} erfordert eine Form mit mindestens zwei Ankerpunkten @msg The shape is too complex for a 3D effect Die Form ist für einen 3D-Effekt zu komplex +@msg Extrude & Bevel Extrudieren und abgeflachte Kante +@msg Revolve Kreiseln +@msg Inflate Aufblasen +@msg Rotate Drehen +@msg Extrude & Bevel (Classic) Extrudieren und abgeflachte Kante (klassisch) +@msg Revolve (Classic) Kreiseln (klassisch) +@msg Rotate (Classic) Drehen (klassisch) +@msg This effect Dieser Effekt +@msg clipping paths Schnittpfade +@msg other objects andere Objekte +@msg {label} requires a group holding a shape with at least two anchors {label} erfordert eine Gruppe mit einer Form mit mindestens zwei Ankerpunkten +@msg Revolve requires a group holding a shape with at least two anchors Kreiseln erfordert eine Gruppe mit einer Form mit mindestens zwei Ankerpunkten +@msg Revolve requires a shape with at least two anchors Kreiseln erfordert eine Form mit mindestens zwei Ankerpunkten +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} auf einer Gruppe braucht Pfade, zusammengesetzte Pfade, Text, Gruppen, Schnittgruppen, Angleichungen und Symbolinstanzen: Geben Sie zuerst Folgendes frei oder wandeln Sie es um: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} auf einer Gruppe nimmt höchstens {MAX_MEMBERS} Objekte auf +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Ein 3D-Effekt auf einer Gruppe oder Ebene gehört auf die Gruppe selbst, nicht auf ihre Fläche oder Kontur +@msg a 3D effect on a group's fill or stroke draws nothing ein 3D-Effekt auf der Fläche oder Kontur einer Gruppe zeichnet nichts +@msg a 3D effect on a group's fill or stroke draws nothing to export ein 3D-Effekt auf der Fläche oder Kontur einer Gruppe zeichnet nichts, das exportiert werden kann +@msg The 3D object is too complex to export Das 3D-Objekt ist zu komplex für den Export +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) das Objekt hat keinen 3D-Effekt (Extrudieren und abgeflachte Kante, Kreiseln, Aufblasen oder Drehen) +@msg its 3D effect builds no solid sein 3D-Effekt erzeugt keinen Körper +@msg bevel must be none or one of {_1} bevel muss none oder einer der folgenden Werte sein: {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} kann aus diesem Bildmaterial keine Körper erstellen: Seine Symbolinstanzen, Angleichungen und Schnittgruppen enthalten zu viele Objekte +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} kann keinen Körper aus der Instanz des Symbols „{symbol}“ erstellen: Das Bildmaterial des Symbols ist nicht verfügbar +@msg {_1}: the clipping path is too complex to cut the art with {_1}: Der Schnittpfad ist zu komplex, um das Bildmaterial damit zu beschneiden +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} nimmt höchstens {MAX_MEMBERS} Objekte auf +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} kann keine Körper aus Bildmaterial erstellen, das mehr als {MAX_DEPTH} Ebenen tief verschachtelt ist +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Die Farbe von Kreiseln ist für diese Oberfläche zu komplex; verringern Sie Segmente oder vereinfachen Sie den Verlauf +@msg Revolve edge must be left or right Kreiseln: edge muss left oder right sein +@msg Revolve cap must be a boolean Kreiseln: cap muss ein boolescher Wert sein +@msg the revolved object has no fill or stroke das gekreiselte Objekt hat keine Fläche oder Kontur +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor muss eine deckende RGB-Farbe der Form #rrggbb sein +@msg lights must contain one to ten lights lights muss ein bis zehn Lichter enthalten +@msg each light must be an object jedes Licht muss ein Objekt sein +@msg unknown light setting: use color, intensity, rotation, height, softness or behind unbekannte Lichteinstellung: Verwenden Sie color, intensity, rotation, height, softness oder behind +@msg light {key} must be a number from {min} to {max} Licht: {key} muss eine Zahl von {min} bis {max} sein +@msg light color must use opaque #rrggbb color eines Lichts muss ein deckendes #rrggbb sein +@msg light behind must be true or false behind eines Lichts muss true oder false sein +@msg shadow must be true or false shadow muss true oder false sein +@msg shadowPosition must be behind or below shadowPosition muss behind oder below sein +@msg {key} must be a number from {min} to {max} {key} muss eine Zahl von {min} bis {max} sein +@msg each of `{GRAPHICS}` must be an object jeder Eintrag von `{GRAPHICS}` muss ein Objekt sein +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` `{GRAPHICS}`-Einträge nehmen symbol, face, x, y, scale und rotation an, nicht `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol jeder Eintrag von `{GRAPHICS}` braucht `symbol`, den Namen eines Symbols +@msg `{GRAPHICS}` face must be front or back face in `{GRAPHICS}` muss front oder back sein +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} in `{GRAPHICS}` muss eine Zahl von {lo} bis {hi} sein +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` muss ein Array sein +@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafiken werden auf ebene Seiten abgebildet: Verwenden Sie den Objekttyp Extrudieren oder Ebene +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` nimmt höchstens {MAX_GRAPHICS} Grafiken auf +@msg A plane has one face: map its graphics onto the front Eine Ebene hat nur eine Seite: Bilden Sie ihre Grafiken auf der Vorderseite ab +@msg The graphics are too complex: simplify the symbols' art Die Grafiken sind zu komplex: Vereinfachen Sie das Bildmaterial der Symbole +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Das Bildmaterial einer Grafik darf höchstens {MAX_LEVELS} Ebenen tief verschachtelt sein: Vereinfachen Sie das Bildmaterial des Symbols +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Das Bildmaterial einer Grafik darf Symbole höchstens {MAX_NESTING} Ebenen tief verschachteln: Vereinfachen Sie das Bildmaterial des Symbols +@msg A graphic's art holds too many objects: simplify the symbol's art Das Bildmaterial einer Grafik enthält zu viele Objekte: Vereinfachen Sie das Bildmaterial des Symbols +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Eine Grafik zeichnet höchstens {MAX_PIECES} Flächen und Konturen: Vereinfachen Sie das Bildmaterial des Symbols +@msg `surface` must be one of {_1} `surface` muss einer der folgenden Werte sein: {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} zeichnet für dieses Objekt das Ausgangsbildmaterial statt seiner 3D-Oberfläche +@msg {label} draws no faces for this object {label} zeichnet für dieses Objekt keine Seiten +@msg the wireframe has more than {MAX_WIRE_EDGES} edges das Drahtmodell hat mehr als {MAX_WIRE_EDGES} Kanten @msg The 3D surface is too complex; simplify the shape Die 3D-Oberfläche ist zu komplex; vereinfachen Sie die Form Transform Objects Objekte transformieren @msg unknown parameter `{key}`; did you mean `{near}`? unbekannter Parameter `{key}`; meinten Sie `{near}`? diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8467b8e831..614f1e6614 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5235,6 +5235,65 @@ digits Malayalam Μαλαγιάλαμ @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. Το {label} δεν μπορεί να δημιουργήσει στερεό από εικόνα: ανιχνεύστε την πρώτα με Αντικείμενο › Ανίχνευση περιγραμμάτων εικόνας › Κατασκευαστής. @msg {label} requires a shape with at least two anchors το {label} απαιτεί σχήμα με τουλάχιστον δύο σημεία αγκύρωσης @msg The shape is too complex for a 3D effect Το σχήμα είναι πολύ σύνθετο για εφέ 3D +@msg Extrude & Bevel Εξώθηση και λοξότμηση +@msg Revolve Περιστροφή γύρω από άξονα +@msg Inflate Φούσκωμα +@msg Rotate Περιστροφή +@msg Extrude & Bevel (Classic) Εξώθηση και λοξότμηση (κλασικό) +@msg Revolve (Classic) Περιστροφή γύρω από άξονα (κλασικό) +@msg Rotate (Classic) Περιστροφή (κλασικό) +@msg This effect Αυτό το εφέ +@msg clipping paths διαδρομές αποκοπής +@msg other objects άλλα αντικείμενα +@msg {label} requires a group holding a shape with at least two anchors {label}: απαιτείται ομάδα με σχήμα με τουλάχιστον δύο σημεία αγκύρωσης +@msg Revolve requires a group holding a shape with at least two anchors Περιστροφή γύρω από άξονα: απαιτείται ομάδα με σχήμα με τουλάχιστον δύο σημεία αγκύρωσης +@msg Revolve requires a shape with at least two anchors Περιστροφή γύρω από άξονα: απαιτείται σχήμα με τουλάχιστον δύο σημεία αγκύρωσης +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} σε ομάδα: απαιτούνται διαδρομές, σύνθετες διαδρομές, κείμενο, ομάδες, ομάδες αποκοπής, αναμείξεις και παρουσίες συμβόλων· πρώτα αποδεσμεύστε ή αναπτύξτε τα εξής: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} σε ομάδα: το πολύ {MAX_MEMBERS} αντικείμενα +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Ένα εφέ 3D σε ομάδα ή επίπεδο εφαρμόζεται στην ίδια την ομάδα, όχι στο γέμισμα ή στην πινελιά της +@msg a 3D effect on a group's fill or stroke draws nothing ένα εφέ 3D στο γέμισμα ή στην πινελιά μιας ομάδας δεν σχεδιάζει τίποτα +@msg a 3D effect on a group's fill or stroke draws nothing to export ένα εφέ 3D στο γέμισμα ή στην πινελιά μιας ομάδας δεν σχεδιάζει τίποτα για εξαγωγή +@msg The 3D object is too complex to export Το αντικείμενο 3D είναι πολύ σύνθετο για εξαγωγή +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) το αντικείμενο δεν έχει εφέ 3D (Εξώθηση και λοξότμηση, Περιστροφή γύρω από άξονα, Φούσκωμα ή Περιστροφή) +@msg its 3D effect builds no solid το εφέ 3D του δεν δημιουργεί στερεό +@msg bevel must be none or one of {_1} το bevel πρέπει να είναι none ή ένα από τα {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1}: δεν μπορούν να δημιουργηθούν στερεά από αυτό το σχέδιο· οι παρουσίες συμβόλων, οι αναμείξεις και οι ομάδες αποκοπής του περιέχουν πάρα πολλά αντικείμενα +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label}: δεν μπορεί να δημιουργηθεί στερεό από την παρουσία του συμβόλου «{symbol}»· το σχέδιο του συμβόλου δεν είναι διαθέσιμο +@msg {_1}: the clipping path is too complex to cut the art with {_1}: η διαδρομή αποκοπής είναι πολύ σύνθετη για να κόψει το σχέδιο +@msg {_1} takes at most {MAX_MEMBERS} objects {_1}: το πολύ {MAX_MEMBERS} αντικείμενα +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1}: δεν μπορούν να δημιουργηθούν στερεά από σχέδιο ένθετο σε περισσότερα από {MAX_DEPTH} επίπεδα +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Το χρώμα της Περιστροφής γύρω από άξονα είναι πολύ σύνθετο για αυτή την επιφάνεια· μειώστε τα Τμήματα ή απλοποιήστε τη διαβάθμιση +@msg Revolve edge must be left or right Περιστροφή γύρω από άξονα: το edge πρέπει να είναι left ή right +@msg Revolve cap must be a boolean Περιστροφή γύρω από άξονα: το cap πρέπει να είναι λογική τιμή +@msg the revolved object has no fill or stroke το περιστρεφόμενο αντικείμενο δεν έχει γέμισμα ούτε πινελιά +@msg lightColor must be an opaque RGB color in #rrggbb form το lightColor πρέπει να είναι αδιαφανές χρώμα RGB της μορφής #rrggbb +@msg lights must contain one to ten lights το lights πρέπει να περιέχει από ένα έως δέκα φώτα +@msg each light must be an object κάθε φως πρέπει να είναι αντικείμενο +@msg unknown light setting: use color, intensity, rotation, height, softness or behind άγνωστη ρύθμιση φωτός: χρησιμοποιήστε color, intensity, rotation, height, softness ή behind +@msg light {key} must be a number from {min} to {max} φως: το {key} πρέπει να είναι αριθμός από {min} έως {max} +@msg light color must use opaque #rrggbb το color ενός φωτός πρέπει να είναι αδιαφανές #rrggbb +@msg light behind must be true or false το behind ενός φωτός πρέπει να είναι true ή false +@msg shadow must be true or false το shadow πρέπει να είναι true ή false +@msg shadowPosition must be behind or below το shadowPosition πρέπει να είναι behind ή below +@msg {key} must be a number from {min} to {max} το {key} πρέπει να είναι αριθμός από {min} έως {max} +@msg each of `{GRAPHICS}` must be an object κάθε στοιχείο του `{GRAPHICS}` πρέπει να είναι αντικείμενο +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` οι καταχωρίσεις του `{GRAPHICS}` δέχονται symbol, face, x, y, scale και rotation, όχι `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol κάθε στοιχείο του `{GRAPHICS}` χρειάζεται `symbol`, το όνομα ενός συμβόλου +@msg `{GRAPHICS}` face must be front or back το face του `{GRAPHICS}` πρέπει να είναι front ή back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} το {key} του `{GRAPHICS}` πρέπει να είναι αριθμός από {lo} έως {hi} +@msg `{GRAPHICS}` must be an array το `{GRAPHICS}` πρέπει να είναι πίνακας +@msg Graphics map onto planar faces: use the Extrude or Plane object type Τα γραφικά αντιστοιχίζονται σε επίπεδες όψεις: χρησιμοποιήστε τον τύπο αντικειμένου Εξώθηση ή Επίπεδο +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics το `{GRAPHICS}` δέχεται το πολύ {MAX_GRAPHICS} γραφικά +@msg A plane has one face: map its graphics onto the front Ένα επίπεδο έχει μία όψη: αντιστοιχίστε τα γραφικά του στην μπροστινή όψη +@msg The graphics are too complex: simplify the symbols' art Τα γραφικά είναι πολύ σύνθετα: απλοποιήστε το σχέδιο των συμβόλων +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Το σχέδιο ενός γραφικού μπορεί να είναι ένθετο το πολύ σε {MAX_LEVELS} επίπεδα: απλοποιήστε το σχέδιο του συμβόλου +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Το σχέδιο ενός γραφικού μπορεί να περιέχει ένθετα σύμβολα το πολύ σε {MAX_NESTING} επίπεδα: απλοποιήστε το σχέδιο του συμβόλου +@msg A graphic's art holds too many objects: simplify the symbol's art Το σχέδιο ενός γραφικού περιέχει πάρα πολλά αντικείμενα: απλοποιήστε το σχέδιο του συμβόλου +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Ένα γραφικό σχεδιάζει το πολύ {MAX_PIECES} γεμίσματα και πινελιές: απλοποιήστε το σχέδιο του συμβόλου +@msg `surface` must be one of {_1} Το `surface` πρέπει να είναι ένα από τα {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label}: σχεδιάζεται το αρχικό σχέδιο αυτού του αντικειμένου αντί για την επιφάνεια 3D του +@msg {label} draws no faces for this object {label}: δεν σχεδιάζεται καμία όψη για αυτό το αντικείμενο +@msg the wireframe has more than {MAX_WIRE_EDGES} edges το συρμάτινο πλέγμα έχει περισσότερες από {MAX_WIRE_EDGES} ακμές @msg The 3D surface is too complex; simplify the shape Η επιφάνεια 3D είναι πολύ σύνθετη· απλοποιήστε το σχήμα Transform Objects Μετασχηματισμός αντικειμένων @msg unknown parameter `{key}`; did you mean `{near}`? άγνωστη παράμετρος `{key}`· μήπως εννοείτε `{near}`; diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 194ba2c602..965227aff5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5817,6 +5817,65 @@ mark-style Japanese Japonesas @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} no puede crear un sólido a partir de una imagen: cálquela primero con Objeto › Calco de imagen › Crear. @msg {label} requires a shape with at least two anchors {label} requiere una forma con al menos dos puntos de ancla @msg The shape is too complex for a 3D effect La forma es demasiado compleja para un efecto 3D +@msg Extrude & Bevel Extrusión y biselado +@msg Revolve Revolución +@msg Inflate Inflar +@msg Rotate Rotar +@msg Extrude & Bevel (Classic) Extrusión y biselado (clásico) +@msg Revolve (Classic) Revolución (clásico) +@msg Rotate (Classic) Rotar (clásico) +@msg This effect Este efecto +@msg clipping paths trazados de recorte +@msg other objects otros objetos +@msg {label} requires a group holding a shape with at least two anchors {label} requiere un grupo que contenga una forma con al menos dos puntos de ancla +@msg Revolve requires a group holding a shape with at least two anchors Revolución requiere un grupo que contenga una forma con al menos dos puntos de ancla +@msg Revolve requires a shape with at least two anchors Revolución requiere una forma con al menos dos puntos de ancla +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} en un grupo necesita trazados, trazados compuestos, texto, grupos, grupos de recorte, fusiones e instancias de símbolo: primero libere o expanda estos elementos: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} en un grupo admite como máximo {MAX_MEMBERS} objetos +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Un efecto 3D en un grupo o una capa se aplica al propio grupo, no a su relleno ni a su trazo +@msg a 3D effect on a group's fill or stroke draws nothing un efecto 3D en el relleno o el trazo de un grupo no dibuja nada +@msg a 3D effect on a group's fill or stroke draws nothing to export un efecto 3D en el relleno o el trazo de un grupo no dibuja nada que exportar +@msg The 3D object is too complex to export El objeto 3D es demasiado complejo para exportarlo +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) el objeto no tiene ningún efecto 3D (Extrusión y biselado, Revolución, Inflar o Rotar) +@msg its 3D effect builds no solid su efecto 3D no construye ningún sólido +@msg bevel must be none or one of {_1} bevel debe ser none o uno de estos: {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} no puede construir sólidos a partir de esta ilustración: sus instancias de símbolo, fusiones y grupos de recorte contienen demasiados objetos +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} no puede construir un sólido a partir de la instancia del símbolo «{symbol}»: la ilustración del símbolo no está disponible +@msg {_1}: the clipping path is too complex to cut the art with {_1}: el trazado de recorte es demasiado complejo para recortar la ilustración +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} admite como máximo {MAX_MEMBERS} objetos +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} no puede construir sólidos a partir de una ilustración anidada en más de {MAX_DEPTH} niveles +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient La pintura de Revolución es demasiado compleja para esta superficie; reduzca Segmentos o simplifique el degradado +@msg Revolve edge must be left or right Revolución: edge debe ser left o right +@msg Revolve cap must be a boolean Revolución: cap debe ser un valor booleano +@msg the revolved object has no fill or stroke el objeto de revolución no tiene relleno ni trazo +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor debe ser un color RGB opaco con el formato #rrggbb +@msg lights must contain one to ten lights lights debe contener de una a diez luces +@msg each light must be an object cada luz debe ser un objeto +@msg unknown light setting: use color, intensity, rotation, height, softness or behind ajuste de luz desconocido: use color, intensity, rotation, height, softness o behind +@msg light {key} must be a number from {min} to {max} luz: {key} debe ser un número de {min} a {max} +@msg light color must use opaque #rrggbb color de una luz debe ser un #rrggbb opaco +@msg light behind must be true or false behind de una luz debe ser true o false +@msg shadow must be true or false shadow debe ser true o false +@msg shadowPosition must be behind or below shadowPosition debe ser behind o below +@msg {key} must be a number from {min} to {max} {key} debe ser un número de {min} a {max} +@msg each of `{GRAPHICS}` must be an object cada elemento de `{GRAPHICS}` debe ser un objeto +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` los elementos de `{GRAPHICS}` admiten symbol, face, x, y, scale y rotation, no `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol cada elemento de `{GRAPHICS}` necesita `symbol`, el nombre de un símbolo +@msg `{GRAPHICS}` face must be front or back face de `{GRAPHICS}` debe ser front o back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} de `{GRAPHICS}` debe ser un número de {lo} a {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` debe ser una matriz +@msg Graphics map onto planar faces: use the Extrude or Plane object type Los gráficos se asignan a caras planas: use el tipo de objeto Extrusión o Plano +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` admite como máximo {MAX_GRAPHICS} gráficos +@msg A plane has one face: map its graphics onto the front Un plano tiene una sola cara: asigne sus gráficos a la cara frontal +@msg The graphics are too complex: simplify the symbols' art Los gráficos son demasiado complejos: simplifique la ilustración de los símbolos +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art La ilustración de un gráfico puede anidarse como máximo en {MAX_LEVELS} niveles: simplifique la ilustración del símbolo +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art La ilustración de un gráfico puede anidar símbolos como máximo en {MAX_NESTING} niveles: simplifique la ilustración del símbolo +@msg A graphic's art holds too many objects: simplify the symbol's art La ilustración de un gráfico contiene demasiados objetos: simplifique la ilustración del símbolo +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Un gráfico dibuja como máximo {MAX_PIECES} rellenos y trazos: simplifique la ilustración del símbolo +@msg `surface` must be one of {_1} `surface` debe ser uno de estos: {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} dibuja la ilustración de origen de este objeto en lugar de su superficie 3D +@msg {label} draws no faces for this object {label} no dibuja ninguna cara para este objeto +@msg the wireframe has more than {MAX_WIRE_EDGES} edges la estructura alámbrica tiene más de {MAX_WIRE_EDGES} aristas @msg The 3D surface is too complex; simplify the shape La superficie 3D es demasiado compleja; simplifique la forma Transform Objects Transformar objetos @msg unknown parameter `{key}`; did you mean `{near}`? parámetro desconocido `{key}`; ¿quiso decir `{near}`? diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e36c5e891e..3443ea2f34 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5758,6 +5758,65 @@ mark-style Japanese Japanilainen @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} ei voi luoda kappaletta kuvasta: jäljitä se ensin komennolla Objekti › Kuvan jäljitys › Luo. @msg {label} requires a shape with at least two anchors {label} vaatii muodon, jossa on vähintään kaksi ankkuria @msg The shape is too complex for a 3D effect Muoto on liian monimutkainen 3D-tehosteelle +@msg Extrude & Bevel Pursota ja viistä +@msg Revolve Pyöräytä +@msg Inflate Täytä ilmalla +@msg Rotate Kierrä +@msg Extrude & Bevel (Classic) Pursota ja viistä (perinteinen) +@msg Revolve (Classic) Pyöräytä (perinteinen) +@msg Rotate (Classic) Kierrä (perinteinen) +@msg This effect Tämä tehoste +@msg clipping paths leikkauspolut +@msg other objects muut objektit +@msg {label} requires a group holding a shape with at least two anchors {label} vaatii ryhmän, jonka muodossa on vähintään kaksi ankkuria +@msg Revolve requires a group holding a shape with at least two anchors Pyöräytä vaatii ryhmän, jonka muodossa on vähintään kaksi ankkuria +@msg Revolve requires a shape with at least two anchors Pyöräytä vaatii muodon, jossa on vähintään kaksi ankkuria +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} ryhmässä vaatii polkuja, yhdistelmäpolkuja, tekstiä, ryhmiä, leikkausryhmiä, sulautuksia ja symbolien esiintymiä: vapauta tai laajenna ensin nämä: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} ryhmässä hyväksyy enintään {MAX_MEMBERS} objektia +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Ryhmän tai tason 3D-tehoste kuuluu itse ryhmälle, ei sen täytölle tai viivalle +@msg a 3D effect on a group's fill or stroke draws nothing ryhmän täytön tai viivan 3D-tehoste ei piirrä mitään +@msg a 3D effect on a group's fill or stroke draws nothing to export ryhmän täytön tai viivan 3D-tehoste ei piirrä mitään vietävää +@msg The 3D object is too complex to export 3D-objekti on liian monimutkainen vietäväksi +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) objektilla ei ole 3D-tehostetta (Pursota ja viistä, Pyöräytä, Täytä ilmalla tai Kierrä) +@msg its 3D effect builds no solid sen 3D-tehoste ei muodosta kappaletta +@msg bevel must be none or one of {_1} bevel-arvon on oltava none tai jokin seuraavista: {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} ei voi muodostaa kappaleita tästä kuvituksesta: sen symbolien esiintymät, sulautukset ja leikkausryhmät sisältävät liikaa objekteja +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} ei voi muodostaa kappaletta symbolin ”{symbol}” esiintymästä: symbolin kuvitus ei ole käytettävissä +@msg {_1}: the clipping path is too complex to cut the art with {_1}: leikkauspolku on liian monimutkainen kuvituksen leikkaamiseen +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} hyväksyy enintään {MAX_MEMBERS} objektia +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} ei voi muodostaa kappaleita kuvituksesta, joka on sisäkkäin yli {MAX_DEPTH} tason syvyydellä +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Pyöräytä-tehosteen maalaus on liian monimutkainen tälle pinnalle; pienennä Segmentit-arvoa tai yksinkertaista liukuväriä +@msg Revolve edge must be left or right Pyöräytä: edge-arvon on oltava left tai right +@msg Revolve cap must be a boolean Pyöräytä: cap-arvon on oltava totuusarvo +@msg the revolved object has no fill or stroke pyöräytetyllä objektilla ei ole täyttöä eikä viivaa +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor-arvon on oltava peittävä RGB-väri muodossa #rrggbb +@msg lights must contain one to ten lights lights-arvossa on oltava yhdestä kymmeneen valoa +@msg each light must be an object jokaisen valon on oltava objekti +@msg unknown light setting: use color, intensity, rotation, height, softness or behind tuntematon valoasetus: käytä arvoja color, intensity, rotation, height, softness tai behind +@msg light {key} must be a number from {min} to {max} valo: {key}-arvon on oltava luku, joka on vähintään {min} ja enintään {max} +@msg light color must use opaque #rrggbb valon color-arvon on oltava peittävä #rrggbb +@msg light behind must be true or false valon behind-arvon on oltava true tai false +@msg shadow must be true or false shadow-arvon on oltava true tai false +@msg shadowPosition must be behind or below shadowPosition-arvon on oltava behind tai below +@msg {key} must be a number from {min} to {max} {key}-arvon on oltava luku, joka on vähintään {min} ja enintään {max} +@msg each of `{GRAPHICS}` must be an object jokaisen `{GRAPHICS}`-kohteen on oltava objekti +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` `{GRAPHICS}`-kohteet hyväksyvät arvot symbol, face, x, y, scale ja rotation, eivät arvoa `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol jokainen `{GRAPHICS}`-kohde tarvitsee arvon `symbol`, symbolin nimen +@msg `{GRAPHICS}` face must be front or back `{GRAPHICS}`-kohteen face-arvon on oltava front tai back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}`-kohteen {key}-arvon on oltava luku, joka on vähintään {lo} ja enintään {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` on oltava taulukko +@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafiikat sijoitetaan tasaisille pinnoille: käytä objektityyppiä Pursota tai Taso +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` hyväksyy enintään {MAX_GRAPHICS} grafiikkaa +@msg A plane has one face: map its graphics onto the front Tasolla on yksi puoli: sijoita sen grafiikat etupuolelle +@msg The graphics are too complex: simplify the symbols' art Grafiikat ovat liian monimutkaisia: yksinkertaista symbolien kuvitusta +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Grafiikan kuvitus voi olla sisäkkäin enintään {MAX_LEVELS} tason syvyydellä: yksinkertaista symbolin kuvitusta +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Grafiikan kuvituksessa symbolit voivat olla sisäkkäin enintään {MAX_NESTING} tason syvyydellä: yksinkertaista symbolin kuvitusta +@msg A graphic's art holds too many objects: simplify the symbol's art Grafiikan kuvitus sisältää liikaa objekteja: yksinkertaista symbolin kuvitusta +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Grafiikka piirtää enintään {MAX_PIECES} täyttöä ja viivaa: yksinkertaista symbolin kuvitusta +@msg `surface` must be one of {_1} `surface` on oltava jokin seuraavista: {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} piirtää tälle objektille lähdekuvituksen sen 3D-pinnan sijaan +@msg {label} draws no faces for this object {label} ei piirrä tälle objektille yhtään pintaa +@msg the wireframe has more than {MAX_WIRE_EDGES} edges rautalankamallissa on yli {MAX_WIRE_EDGES} reunaa @msg The 3D surface is too complex; simplify the shape 3D-pinta on liian monimutkainen; yksinkertaista muotoa Transform Objects Muunna objekteja @msg unknown parameter `{key}`; did you mean `{near}`? tuntematon parametri `{key}`; tarkoititko `{near}`? diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 067845f493..211ac06722 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5446,6 +5446,65 @@ lighting Light Lumière @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} ne peut pas créer de solide à partir d'une image : vectorisez-la d'abord avec Objet › Vectorisation de l’image › Créer. @msg {label} requires a shape with at least two anchors {label} nécessite une forme comportant au moins deux points d'ancrage @msg The shape is too complex for a 3D effect La forme est trop complexe pour un effet 3D +@msg Extrude & Bevel Extrusion et biseautage +@msg Revolve Révolution +@msg Inflate Gonfler +@msg Rotate Rotation +@msg Extrude & Bevel (Classic) Extrusion et biseautage (classique) +@msg Revolve (Classic) Révolution (classique) +@msg Rotate (Classic) Rotation (classique) +@msg This effect Cet effet +@msg clipping paths tracés de découpe +@msg other objects autres objets +@msg {label} requires a group holding a shape with at least two anchors {label} nécessite un groupe contenant une forme comportant au moins deux points d'ancrage +@msg Revolve requires a group holding a shape with at least two anchors Révolution nécessite un groupe contenant une forme comportant au moins deux points d'ancrage +@msg Revolve requires a shape with at least two anchors Révolution nécessite une forme comportant au moins deux points d'ancrage +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} sur un groupe nécessite des tracés, tracés transparents, textes, groupes, groupes de découpe, dégradés de formes et occurrences de symbole : libérez ou développez d'abord ces éléments : {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} sur un groupe accepte au plus {MAX_MEMBERS} objets +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Un effet 3D sur un groupe ou un calque s'applique au groupe lui-même, pas à son fond ni à son contour +@msg a 3D effect on a group's fill or stroke draws nothing un effet 3D sur le fond ou le contour d'un groupe ne dessine rien +@msg a 3D effect on a group's fill or stroke draws nothing to export un effet 3D sur le fond ou le contour d'un groupe ne dessine rien à exporter +@msg The 3D object is too complex to export L'objet 3D est trop complexe pour être exporté +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) l'objet n'a aucun effet 3D (Extrusion et biseautage, Révolution, Gonfler ou Rotation) +@msg its 3D effect builds no solid son effet 3D ne construit aucun volume +@msg bevel must be none or one of {_1} bevel doit valoir none ou l’une de ces valeurs : {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} ne peut pas construire de volumes à partir de cette illustration : ses occurrences de symbole, dégradés de formes et groupes de découpe contiennent trop d'objets +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} ne peut pas construire de volume à partir de l'occurrence du symbole « {symbol} » : l'illustration du symbole n'est pas disponible +@msg {_1}: the clipping path is too complex to cut the art with {_1} : le tracé de découpe est trop complexe pour découper l'illustration +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} accepte au plus {MAX_MEMBERS} objets +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} ne peut pas construire de volumes à partir d'une illustration imbriquée sur plus de {MAX_DEPTH} niveaux +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient La peinture de Révolution est trop complexe pour cette surface ; réduisez Segments ou simplifiez le dégradé +@msg Revolve edge must be left or right Révolution : edge doit valoir left ou right +@msg Revolve cap must be a boolean Révolution : cap doit être un booléen +@msg the revolved object has no fill or stroke l'objet en révolution n'a ni fond ni contour +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor doit être une couleur RVB opaque au format #rrggbb +@msg lights must contain one to ten lights lights doit contenir de une à dix lumières +@msg each light must be an object chaque lumière doit être un objet +@msg unknown light setting: use color, intensity, rotation, height, softness or behind réglage de lumière inconnu : utilisez color, intensity, rotation, height, softness ou behind +@msg light {key} must be a number from {min} to {max} lumière : {key} doit être un nombre de {min} à {max} +@msg light color must use opaque #rrggbb color d'une lumière doit être une couleur #rrggbb opaque +@msg light behind must be true or false behind d'une lumière doit valoir true ou false +@msg shadow must be true or false shadow doit valoir true ou false +@msg shadowPosition must be behind or below shadowPosition doit valoir behind ou below +@msg {key} must be a number from {min} to {max} {key} doit être un nombre de {min} à {max} +@msg each of `{GRAPHICS}` must be an object chaque élément de `{GRAPHICS}` doit être un objet +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` les éléments de `{GRAPHICS}` acceptent symbol, face, x, y, scale et rotation, pas `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol chaque élément de `{GRAPHICS}` nécessite `symbol`, le nom d'un symbole +@msg `{GRAPHICS}` face must be front or back face de `{GRAPHICS}` doit valoir front ou back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} de `{GRAPHICS}` doit être un nombre de {lo} à {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` doit être un tableau +@msg Graphics map onto planar faces: use the Extrude or Plane object type Les graphiques s'appliquent sur des faces planes : utilisez le type d'objet Extrusion ou Plan +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` accepte au plus {MAX_GRAPHICS} graphiques +@msg A plane has one face: map its graphics onto the front Un plan n'a qu'une face : appliquez ses graphiques sur la face avant +@msg The graphics are too complex: simplify the symbols' art Les graphiques sont trop complexes : simplifiez l'illustration des symboles +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art L'illustration d'un graphique peut être imbriquée sur {MAX_LEVELS} niveaux au plus : simplifiez l'illustration du symbole +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art L'illustration d'un graphique peut imbriquer des symboles sur {MAX_NESTING} niveaux au plus : simplifiez l'illustration du symbole +@msg A graphic's art holds too many objects: simplify the symbol's art L'illustration d'un graphique contient trop d'objets : simplifiez l'illustration du symbole +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Un graphique dessine au plus {MAX_PIECES} fonds et contours : simplifiez l'illustration du symbole +@msg `surface` must be one of {_1} `surface` doit valoir l’une de ces valeurs : {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} dessine l'illustration source de cet objet au lieu de sa surface 3D +@msg {label} draws no faces for this object {label} ne dessine aucune face pour cet objet +@msg the wireframe has more than {MAX_WIRE_EDGES} edges le fil de fer comporte plus de {MAX_WIRE_EDGES} arêtes @msg The 3D surface is too complex; simplify the shape La surface 3D est trop complexe ; simplifiez la forme Transform Objects Transformer les objets @msg unknown parameter `{key}`; did you mean `{near}`? paramètre inconnu `{key}` ; vouliez-vous dire `{near}` ? diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 165e04ae7d..99bcd1bf37 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7824,6 +7824,65 @@ antiAlias Sharp शार्प @msg that makes too many pieces (at most 100000): make the dashes longer जो बहुत सारे टुकड़े बनाता है (अधिकांश 100000 में): डैश को लंबे समय तक बनाते हैं @msg that object is not in the overlap उस ऑब्जेक्ट को ओवरलैप में नहीं है @msg the {tool} tool has no options {tool} टूल में कोई ऑप्शन्स नहीं है +@msg Extrude & Bevel एक्सट्रूडेड और बेवेल +@msg Revolve रिवॉल्व +@msg Inflate फुलाएँ +@msg Rotate घुमाव +@msg Extrude & Bevel (Classic) एक्सट्रूडेड और बेवेल (क्लासिक) +@msg Revolve (Classic) रिवॉल्व (क्लासिक) +@msg Rotate (Classic) घुमाव (क्लासिक) +@msg This effect यह इफ़ेक्ट +@msg clipping paths क्लिपिंग पाथ +@msg other objects अन्य ऑब्जेक्ट +@msg {label} requires a group holding a shape with at least two anchors {label} को कम से कम दो एंकरों वाले आकार से युक्त ग्रुप की आवश्यकता होती है +@msg Revolve requires a group holding a shape with at least two anchors रिवॉल्व को कम से कम दो एंकरों वाले आकार से युक्त ग्रुप की आवश्यकता होती है +@msg Revolve requires a shape with at least two anchors रिवॉल्व को कम से कम दो एंकरों वाले आकार की आवश्यकता होती है +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. ग्रुप पर {label} के लिए पाथ, कंपाउंड पाथ, टाइप, ग्रुप, क्लिपिंग ग्रुप, ब्लेंड और सिंबल इंस्टेंस चाहिए: पहले इन्हें रिलीज़ या एक्सपैंड करें: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects ग्रुप पर {label} अधिकतम {MAX_MEMBERS} ऑब्जेक्ट लेता है +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke किसी ग्रुप या लेयर पर 3D इफ़ेक्ट ग्रुप पर ही लगता है, उसके फ़िल या स्ट्रोक पर नहीं +@msg a 3D effect on a group's fill or stroke draws nothing ग्रुप के फ़िल या स्ट्रोक पर 3D इफ़ेक्ट कुछ नहीं बनाता +@msg a 3D effect on a group's fill or stroke draws nothing to export ग्रुप के फ़िल या स्ट्रोक पर 3D इफ़ेक्ट एक्सपोर्ट करने लायक कुछ नहीं बनाता +@msg The 3D object is too complex to export 3D ऑब्जेक्ट एक्सपोर्ट करने के लिए बहुत जटिल है +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) ऑब्जेक्ट पर कोई 3D इफ़ेक्ट नहीं है (एक्सट्रूडेड और बेवेल, रिवॉल्व, फुलाएँ या घुमाव) +@msg its 3D effect builds no solid इसका 3D इफ़ेक्ट कोई ठोस नहीं बनाता +@msg bevel must be none or one of {_1} bevel को none या {_1} में से एक होना चाहिए +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} इस आर्ट से ठोस नहीं बना सकता: इसके सिंबल इंस्टेंस, ब्लेंड और क्लिपिंग ग्रुप में बहुत अधिक ऑब्जेक्ट हैं +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} सिंबल "{symbol}" के इंस्टेंस से ठोस नहीं बना सकता: सिंबल का आर्ट उपलब्ध नहीं है +@msg {_1}: the clipping path is too complex to cut the art with {_1}: क्लिपिंग पाथ आर्ट को काटने के लिए बहुत जटिल है +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} अधिकतम {MAX_MEMBERS} ऑब्जेक्ट लेता है +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} {MAX_DEPTH} स्तर से अधिक गहराई तक नेस्ट किए गए आर्ट से ठोस नहीं बना सकता +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient रिवॉल्व का पेंट इस सतह के लिए बहुत जटिल है; सेगमेंट कम करें या ग्रेडिएंट को सरल बनाएँ +@msg Revolve edge must be left or right रिवॉल्व: edge को left या right होना चाहिए +@msg Revolve cap must be a boolean रिवॉल्व: cap एक बूलियन होना चाहिए +@msg the revolved object has no fill or stroke रिवॉल्व किए गए ऑब्जेक्ट में कोई फ़िल या स्ट्रोक नहीं है +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor को #rrggbb रूप में अपारदर्शी RGB रंग होना चाहिए +@msg lights must contain one to ten lights lights में एक से दस लाइटें होनी चाहिए +@msg each light must be an object हर लाइट एक ऑब्जेक्ट होनी चाहिए +@msg unknown light setting: use color, intensity, rotation, height, softness or behind अज्ञात लाइट सेटिंग: color, intensity, rotation, height, softness या behind का उपयोग करें +@msg light {key} must be a number from {min} to {max} लाइट: {key} को {min} से {max} तक की संख्या होना चाहिए +@msg light color must use opaque #rrggbb लाइट का color अपारदर्शी #rrggbb होना चाहिए +@msg light behind must be true or false लाइट का behind, true या false होना चाहिए +@msg shadow must be true or false shadow को true या false होना चाहिए +@msg shadowPosition must be behind or below shadowPosition को behind या below होना चाहिए +@msg {key} must be a number from {min} to {max} {key} को {min} से {max} तक की संख्या होना चाहिए +@msg each of `{GRAPHICS}` must be an object `{GRAPHICS}` की हर प्रविष्टि एक ऑब्जेक्ट होनी चाहिए +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` `{GRAPHICS}` की प्रविष्टियाँ symbol, face, x, y, scale और rotation लेती हैं, `{k}` नहीं +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol `{GRAPHICS}` की हर प्रविष्टि को `symbol` चाहिए, यानी किसी सिंबल का नाम +@msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` का face, front या back होना चाहिए +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` का {key}, {lo} से {hi} तक की संख्या होना चाहिए +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` एक ऐरे होना चाहिए +@msg Graphics map onto planar faces: use the Extrude or Plane object type ग्राफ़िक्स समतल फ़ेस पर लगते हैं: एक्सट्रूड या समतल ऑब्जेक्ट प्रकार का उपयोग करें +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` अधिकतम {MAX_GRAPHICS} ग्राफ़िक्स लेता है +@msg A plane has one face: map its graphics onto the front समतल का एक ही फ़ेस होता है: उसके ग्राफ़िक्स को सामने वाले फ़ेस पर लगाएँ +@msg The graphics are too complex: simplify the symbols' art ग्राफ़िक्स बहुत जटिल हैं: सिंबल्स के आर्ट को सरल बनाएँ +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art ग्राफ़िक का आर्ट अधिकतम {MAX_LEVELS} स्तर तक नेस्ट हो सकता है: सिंबल के आर्ट को सरल बनाएँ +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art ग्राफ़िक का आर्ट सिंबल को अधिकतम {MAX_NESTING} स्तर तक नेस्ट कर सकता है: सिंबल के आर्ट को सरल बनाएँ +@msg A graphic's art holds too many objects: simplify the symbol's art ग्राफ़िक के आर्ट में बहुत अधिक ऑब्जेक्ट हैं: सिंबल के आर्ट को सरल बनाएँ +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art एक ग्राफ़िक अधिकतम {MAX_PIECES} फ़िल और स्ट्रोक बनाता है: सिंबल के आर्ट को सरल बनाएँ +@msg `surface` must be one of {_1} `surface` को {_1} में से एक होना चाहिए +@msg {label} draws the source art for this object instead of its 3D surface {label} इस ऑब्जेक्ट के लिए उसकी 3D सतह के बजाय स्रोत आर्ट बनाता है +@msg {label} draws no faces for this object {label} इस ऑब्जेक्ट के लिए कोई फ़ेस नहीं बनाता +@msg the wireframe has more than {MAX_WIRE_EDGES} edges वायरफ़्रेम में {MAX_WIRE_EDGES} से अधिक किनारे हैं @msg The 3D surface is too complex; simplify the shape 3D सतह बहुत जटिल है; आकार को सरल बनाएं @msg the action has no included steps कार्रवाई में कोई कदम शामिल नहीं है @msg the action has no steps कार्रवाई में कोई कदम नहीं है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2ccf9d57bc..5080511bb2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5859,6 +5859,65 @@ imageTrace Outline Körvonal @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} képből nem tud testet létrehozni: előbb vektorizálja az Objektum › Képvektorizálás › Létrehozás paranccsal. @msg {label} requires a shape with at least two anchors {label} legalább két szerkesztőpontos alakzatot igényel @msg The shape is too complex for a 3D effect Az alakzat túl összetett a 3D effektushoz +@msg Extrude & Bevel Kihúzás és élletörés +@msg Revolve Forgatás tengely körül +@msg Inflate Felfújás +@msg Rotate Elforgatás +@msg Extrude & Bevel (Classic) Kihúzás és élletörés (klasszikus) +@msg Revolve (Classic) Forgatás tengely körül (klasszikus) +@msg Rotate (Classic) Elforgatás (klasszikus) +@msg This effect Ez az effektus +@msg clipping paths vágóútvonalak +@msg other objects egyéb objektumok +@msg {label} requires a group holding a shape with at least two anchors {label}: legalább két szerkesztőpontos alakzatot tartalmazó csoport szükséges +@msg Revolve requires a group holding a shape with at least two anchors Forgatás tengely körül: legalább két szerkesztőpontos alakzatot tartalmazó csoport szükséges +@msg Revolve requires a shape with at least two anchors Forgatás tengely körül: legalább két szerkesztőpontos alakzat szükséges +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} csoporton: útvonalak, összetett útvonalak, szöveg, csoportok, vágócsoportok, összemosások és szimbólumpéldányok szükségesek; előbb bontsa fel vagy alakítsa át ezeket: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} csoporton: legfeljebb {MAX_MEMBERS} objektum lehet +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke A csoporton vagy rétegen lévő 3D effektus magára a csoportra kerül, nem a kitöltésére vagy körvonalára +@msg a 3D effect on a group's fill or stroke draws nothing a csoport kitöltésén vagy körvonalán lévő 3D effektus nem rajzol semmit +@msg a 3D effect on a group's fill or stroke draws nothing to export a csoport kitöltésén vagy körvonalán lévő 3D effektus nem rajzol semmi exportálhatót +@msg The 3D object is too complex to export A 3D objektum túl összetett az exportáláshoz +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) az objektumnak nincs 3D effektusa (Kihúzás és élletörés, Forgatás tengely körül, Felfújás vagy Elforgatás) +@msg its 3D effect builds no solid a 3D effektusa nem hoz létre testet +@msg bevel must be none or one of {_1} a bevel értéke none vagy ezek egyike lehet: {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1}: ebből a rajzból nem hozható létre test: a szimbólumpéldányai, összemosásai és vágócsoportjai túl sok objektumot tartalmaznak +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label}: nem hozható létre test a(z) „{symbol}” szimbólum példányából: a szimbólum rajza nem érhető el +@msg {_1}: the clipping path is too complex to cut the art with {_1}: a vágóútvonal túl összetett a rajz kivágásához +@msg {_1} takes at most {MAX_MEMBERS} objects {_1}: legfeljebb {MAX_MEMBERS} objektum lehet +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1}: nem hozható létre test {MAX_DEPTH} szintnél mélyebben egymásba ágyazott rajzból +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient A Forgatás tengely körül festése túl összetett ehhez a felülethez; csökkentse a Szakaszok értékét, vagy egyszerűsítse a színátmenetet +@msg Revolve edge must be left or right Forgatás tengely körül: az edge értéke left vagy right lehet +@msg Revolve cap must be a boolean Forgatás tengely körül: a cap értéke logikai érték lehet +@msg the revolved object has no fill or stroke a forgatott objektumnak nincs kitöltése vagy körvonala +@msg lightColor must be an opaque RGB color in #rrggbb form a lightColor értéke #rrggbb formájú átlátszatlan RGB szín lehet +@msg lights must contain one to ten lights a lights egy és tíz közötti számú fényt tartalmazhat +@msg each light must be an object minden fénynek objektumnak kell lennie +@msg unknown light setting: use color, intensity, rotation, height, softness or behind ismeretlen fénybeállítás: ezeket használja: color, intensity, rotation, height, softness vagy behind +@msg light {key} must be a number from {min} to {max} fény: a {key} értéke {min} és {max} közötti szám lehet +@msg light color must use opaque #rrggbb egy fény color értéke átlátszatlan #rrggbb lehet +@msg light behind must be true or false egy fény behind értéke true vagy false lehet +@msg shadow must be true or false a shadow értéke true vagy false lehet +@msg shadowPosition must be behind or below a shadowPosition értéke behind vagy below lehet +@msg {key} must be a number from {min} to {max} a {key} értéke {min} és {max} közötti szám lehet +@msg each of `{GRAPHICS}` must be an object a `{GRAPHICS}` minden elemének objektumnak kell lennie +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` a `{GRAPHICS}` elemei symbol, face, x, y, scale és rotation értéket fogadnak, nem `{k}` értéket +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol a `{GRAPHICS}` minden eleméhez kell `symbol`, egy szimbólum neve +@msg `{GRAPHICS}` face must be front or back a `{GRAPHICS}` face értéke front vagy back lehet +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} a `{GRAPHICS}` {key} értéke {lo} és {hi} közötti szám lehet +@msg `{GRAPHICS}` must be an array a `{GRAPHICS}` értéke tömb kell legyen +@msg Graphics map onto planar faces: use the Extrude or Plane object type A grafikák sík lapokra kerülnek: használja a Kihúzás vagy a Sík objektumtípust +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics a `{GRAPHICS}` legfeljebb {MAX_GRAPHICS} grafikát fogad +@msg A plane has one face: map its graphics onto the front A síknak egy oldala van: a grafikáit az elülső oldalra helyezze +@msg The graphics are too complex: simplify the symbols' art A grafikák túl összetettek: egyszerűsítse a szimbólumok rajzát +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Egy grafika rajza legfeljebb {MAX_LEVELS} szint mélyen lehet egymásba ágyazva: egyszerűsítse a szimbólum rajzát +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Egy grafika rajza legfeljebb {MAX_NESTING} szint mélyen ágyazhat egymásba szimbólumokat: egyszerűsítse a szimbólum rajzát +@msg A graphic's art holds too many objects: simplify the symbol's art Egy grafika rajza túl sok objektumot tartalmaz: egyszerűsítse a szimbólum rajzát +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Egy grafika legfeljebb {MAX_PIECES} kitöltést és körvonalat rajzol: egyszerűsítse a szimbólum rajzát +@msg `surface` must be one of {_1} `surface`: ezek egyike kell: {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label}: ehhez az objektumhoz a forrásrajzot rajzolja a 3D felülete helyett +@msg {label} draws no faces for this object {label}: ehhez az objektumhoz nem rajzol lapot +@msg the wireframe has more than {MAX_WIRE_EDGES} edges a drótváznak több mint {MAX_WIRE_EDGES} éle van @msg The 3D surface is too complex; simplify the shape A 3D felület túl összetett; egyszerűsítse az alakzatot Transform Objects Objektumok transzformálása @msg unknown parameter `{key}`; did you mean `{near}`? ismeretlen paraméter: `{key}`; erre gondolt: `{near}`? diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index be83d78b43..88ac2910f0 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5493,6 +5493,65 @@ lighting Light Luce @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} non può creare un solido da un'immagine: ricalcatela prima con Oggetto › Ricalco immagine › Crea. @msg {label} requires a shape with at least two anchors {label} richiede una forma con almeno due punti di ancoraggio @msg The shape is too complex for a 3D effect La forma è troppo complessa per un effetto 3D +@msg Extrude & Bevel Estrusione e smusso +@msg Revolve Rivoluzione +@msg Inflate Gonfia +@msg Rotate Ruota +@msg Extrude & Bevel (Classic) Estrusione e smusso (classico) +@msg Revolve (Classic) Rivoluzione (classico) +@msg Rotate (Classic) Ruota (classico) +@msg This effect Questo effetto +@msg clipping paths tracciati di ritaglio +@msg other objects altri oggetti +@msg {label} requires a group holding a shape with at least two anchors {label} richiede un gruppo che contenga una forma con almeno due punti di ancoraggio +@msg Revolve requires a group holding a shape with at least two anchors Rivoluzione richiede un gruppo che contenga una forma con almeno due punti di ancoraggio +@msg Revolve requires a shape with at least two anchors Rivoluzione richiede una forma con almeno due punti di ancoraggio +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} su un gruppo richiede tracciati, tracciati composti, testo, gruppi, gruppi di ritaglio, fusioni e istanze di simbolo: prima rilasciate o espandete questi elementi: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} su un gruppo accetta al massimo {MAX_MEMBERS} oggetti +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Un effetto 3D su un gruppo o un livello va applicato al gruppo stesso, non al suo riempimento o alla sua traccia +@msg a 3D effect on a group's fill or stroke draws nothing un effetto 3D sul riempimento o sulla traccia di un gruppo non disegna nulla +@msg a 3D effect on a group's fill or stroke draws nothing to export un effetto 3D sul riempimento o sulla traccia di un gruppo non disegna nulla da esportare +@msg The 3D object is too complex to export L'oggetto 3D è troppo complesso per essere esportato +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) l'oggetto non ha alcun effetto 3D (Estrusione e smusso, Rivoluzione, Gonfia o Ruota) +@msg its 3D effect builds no solid il suo effetto 3D non costruisce alcun solido +@msg bevel must be none or one of {_1} bevel deve essere none o uno dei seguenti: {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} non può costruire solidi da questo disegno: le sue istanze di simbolo, fusioni e gruppi di ritaglio contengono troppi oggetti +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} non può costruire un solido dall'istanza del simbolo «{symbol}»: il disegno del simbolo non è disponibile +@msg {_1}: the clipping path is too complex to cut the art with {_1}: il tracciato di ritaglio è troppo complesso per ritagliare il disegno +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} accetta al massimo {MAX_MEMBERS} oggetti +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} non può costruire solidi da un disegno nidificato per più di {MAX_DEPTH} livelli +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient La pittura di Rivoluzione è troppo complessa per questa superficie; riducete Segmenti o semplificate la sfumatura +@msg Revolve edge must be left or right Rivoluzione: edge deve essere left o right +@msg Revolve cap must be a boolean Rivoluzione: cap deve essere un valore booleano +@msg the revolved object has no fill or stroke l'oggetto in rivoluzione non ha riempimento né traccia +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor deve essere un colore RGB opaco nel formato #rrggbb +@msg lights must contain one to ten lights lights deve contenere da una a dieci luci +@msg each light must be an object ogni luce deve essere un oggetto +@msg unknown light setting: use color, intensity, rotation, height, softness or behind impostazione della luce sconosciuta: usate color, intensity, rotation, height, softness o behind +@msg light {key} must be a number from {min} to {max} luce: {key} deve essere un numero da {min} a {max} +@msg light color must use opaque #rrggbb color di una luce deve essere un #rrggbb opaco +@msg light behind must be true or false behind di una luce deve essere true o false +@msg shadow must be true or false shadow deve essere true o false +@msg shadowPosition must be behind or below shadowPosition deve essere behind o below +@msg {key} must be a number from {min} to {max} {key} deve essere un numero da {min} a {max} +@msg each of `{GRAPHICS}` must be an object ogni voce di `{GRAPHICS}` deve essere un oggetto +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` le voci di `{GRAPHICS}` accettano symbol, face, x, y, scale e rotation, non `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol ogni voce di `{GRAPHICS}` richiede `symbol`, il nome di un simbolo +@msg `{GRAPHICS}` face must be front or back face di `{GRAPHICS}` deve essere front o back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} di `{GRAPHICS}` deve essere un numero da {lo} a {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` deve essere un array +@msg Graphics map onto planar faces: use the Extrude or Plane object type Le grafiche si applicano a facce piane: usate il tipo di oggetto Estrusione o Piano +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` accetta al massimo {MAX_GRAPHICS} grafiche +@msg A plane has one face: map its graphics onto the front Un piano ha una sola faccia: applicate le sue grafiche sulla faccia anteriore +@msg The graphics are too complex: simplify the symbols' art Le grafiche sono troppo complesse: semplificate il disegno dei simboli +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Il disegno di una grafica può essere nidificato al massimo per {MAX_LEVELS} livelli: semplificate il disegno del simbolo +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Il disegno di una grafica può nidificare simboli al massimo per {MAX_NESTING} livelli: semplificate il disegno del simbolo +@msg A graphic's art holds too many objects: simplify the symbol's art Il disegno di una grafica contiene troppi oggetti: semplificate il disegno del simbolo +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Una grafica disegna al massimo {MAX_PIECES} riempimenti e tracce: semplificate il disegno del simbolo +@msg `surface` must be one of {_1} `surface` deve essere uno dei seguenti: {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} mostra il disegno di origine di questo oggetto al posto della sua superficie 3D +@msg {label} draws no faces for this object {label} non disegna alcuna faccia per questo oggetto +@msg the wireframe has more than {MAX_WIRE_EDGES} edges il wireframe ha più di {MAX_WIRE_EDGES} spigoli @msg The 3D surface is too complex; simplify the shape La superficie 3D è troppo complessa; semplificate la forma Transform Objects Trasforma oggetti @msg unknown parameter `{key}`; did you mean `{near}`? parametro sconosciuto `{key}`; forse intendevate `{near}`? diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b27c6d2698..1508ebd909 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5535,6 +5535,65 @@ lighting Light ライト @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} は画像から立体を作成できません。先に「オブジェクト › 画像トレース › 作成」でトレースしてください。 @msg {label} requires a shape with at least two anchors {label} には 2 つ以上のアンカーポイントを持つシェイプが必要です @msg The shape is too complex for a 3D effect シェイプが複雑すぎるため 3D 効果を適用できません +@msg Extrude & Bevel 押し出しとベベル +@msg Revolve 回転体 +@msg Inflate 膨張 +@msg Rotate 回転 +@msg Extrude & Bevel (Classic) 押し出しとベベル(クラシック) +@msg Revolve (Classic) 回転体(クラシック) +@msg Rotate (Classic) 回転(クラシック) +@msg This effect この効果 +@msg clipping paths クリッピングパス +@msg other objects その他のオブジェクト +@msg {label} requires a group holding a shape with at least two anchors {label} には 2 つ以上のアンカーポイントを持つシェイプを含むグループが必要です +@msg Revolve requires a group holding a shape with at least two anchors 回転体には 2 つ以上のアンカーポイントを持つシェイプを含むグループが必要です +@msg Revolve requires a shape with at least two anchors 回転体には 2 つ以上のアンカーポイントを持つシェイプが必要です +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. グループの {label} には、パス、複合パス、テキスト、グループ、クリッピンググループ、ブレンド、シンボルインスタンスが必要です。先に次を解除または分割してください: {what}。 +@msg {label} on a group takes at most {MAX_MEMBERS} objects グループの {label} で扱えるオブジェクトは {MAX_MEMBERS} 個までです +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke グループまたはレイヤーの 3D 効果は、塗りや線ではなくグループ自体に適用されます +@msg a 3D effect on a group's fill or stroke draws nothing グループの塗りまたは線にある 3D 効果は何も描画しません +@msg a 3D effect on a group's fill or stroke draws nothing to export グループの塗りまたは線にある 3D 効果には書き出せる描画がありません +@msg The 3D object is too complex to export 3D オブジェクトが複雑すぎるため書き出せません +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) オブジェクトに 3D 効果 (押し出しとベベル、回転体、膨張、回転) がありません +@msg its 3D effect builds no solid 3D 効果からソリッドが作成されません +@msg bevel must be none or one of {_1} bevel には none または {_1} のいずれかを指定してください +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} はこのアートからソリッドを作成できません。シンボルインスタンス、ブレンド、クリッピンググループに含まれるオブジェクトが多すぎます +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} はシンボル「{symbol}」のインスタンスからソリッドを作成できません。シンボルのアートを使用できません +@msg {_1}: the clipping path is too complex to cut the art with {_1}: クリッピングパスが複雑すぎるため、アートを切り抜けません +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} で扱えるオブジェクトは {MAX_MEMBERS} 個までです +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} は {MAX_DEPTH} 階層より深く入れ子になったアートからソリッドを作成できません +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient 回転体のペイントがこのサーフェスには複雑すぎます。分割数を減らすか、グラデーションを単純化してください +@msg Revolve edge must be left or right 回転体: edge には left または right を指定してください +@msg Revolve cap must be a boolean 回転体: cap はブール値にしてください +@msg the revolved object has no fill or stroke 回転体のオブジェクトに塗りも線もありません +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor は #rrggbb 形式の不透明な RGB カラーにしてください +@msg lights must contain one to ten lights lights には 1 から 10 個のライトを含めてください +@msg each light must be an object 各ライトはオブジェクトにしてください +@msg unknown light setting: use color, intensity, rotation, height, softness or behind 不明なライトの設定です。color、intensity、rotation、height、softness、behind のいずれかを使用してください +@msg light {key} must be a number from {min} to {max} ライト: {key} は {min} から {max} までの数値にしてください +@msg light color must use opaque #rrggbb ライトの color は不透明な #rrggbb にしてください +@msg light behind must be true or false ライトの behind は true または false にしてください +@msg shadow must be true or false shadow は true または false にしてください +@msg shadowPosition must be behind or below shadowPosition には behind または below を指定してください +@msg {key} must be a number from {min} to {max} {key} は {min} から {max} までの数値にしてください +@msg each of `{GRAPHICS}` must be an object `{GRAPHICS}` の各項目はオブジェクトにしてください +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` `{GRAPHICS}` の項目に指定できるのは symbol、face、x、y、scale、rotation で、`{k}` は指定できません +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol `{GRAPHICS}` の各項目にはシンボルの名前 `symbol` が必要です +@msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` の face には front または back を指定してください +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` の {key} は {lo} から {hi} までの数値にしてください +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` は配列にしてください +@msg Graphics map onto planar faces: use the Extrude or Plane object type グラフィックは平らな面にマッピングされます。オブジェクトの種類に押し出しまたは平面を使用してください +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` に指定できるグラフィックは {MAX_GRAPHICS} 個までです +@msg A plane has one face: map its graphics onto the front 平面には面が 1 つしかありません。グラフィックは前面にマッピングしてください +@msg The graphics are too complex: simplify the symbols' art グラフィックが複雑すぎます。シンボルのアートを単純化してください +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art グラフィックのアートの入れ子は {MAX_LEVELS} 階層までです。シンボルのアートを単純化してください +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art グラフィックのアートでシンボルを入れ子にできるのは {MAX_NESTING} 階層までです。シンボルのアートを単純化してください +@msg A graphic's art holds too many objects: simplify the symbol's art グラフィックのアートに含まれるオブジェクトが多すぎます。シンボルのアートを単純化してください +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art グラフィックが描画できる塗りと線は {MAX_PIECES} 個までです。シンボルのアートを単純化してください +@msg `surface` must be one of {_1} `surface` は {_1} のいずれかにしてください +@msg {label} draws the source art for this object instead of its 3D surface {label} はこのオブジェクトの 3D サーフェスの代わりに元のアートを描画します +@msg {label} draws no faces for this object {label} はこのオブジェクトに面を描画しません +@msg the wireframe has more than {MAX_WIRE_EDGES} edges ワイヤーフレームの辺が {MAX_WIRE_EDGES} 本を超えています @msg The 3D surface is too complex; simplify the shape 3D サーフェスが複雑すぎます。シェイプを単純化してください Transform Objects オブジェクトの変形 @msg unknown parameter `{key}`; did you mean `{near}`? `{key}` は不明なパラメーターです。`{near}` のことですか? diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 4ba40baab0..e8eed0d97d 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5164,6 +5164,65 @@ font-technology Variable ცვლადი @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}: გამოსახულებიდან მყარი სხეულის აგება შეუძლებელია. ჯერ მოახდინეთ მისი ვექტორიზაცია ბრძანებით ობიექტი › გამოსახულების ვექტორიზაცია › შექმნა. @msg {label} requires a shape with at least two anchors {label}: საჭიროა ფიგურა მინიმუმ ორი საყრდენი წერტილით @msg The shape is too complex for a 3D effect ფიგურა ზედმეტად რთულია 3D ეფექტისთვის +@msg Extrude & Bevel ამოწევა და წაკვეთა +@msg Revolve ბრუნვა +@msg Inflate გაბერვა +@msg Rotate შემობრუნება +@msg Extrude & Bevel (Classic) ამოწევა და წაკვეთა (კლასიკური) +@msg Revolve (Classic) ბრუნვა (კლასიკური) +@msg Rotate (Classic) შემობრუნება (კლასიკური) +@msg This effect ეს ეფექტი +@msg clipping paths მომჭრელი ტრაექტორიები +@msg other objects სხვა ობიექტები +@msg {label} requires a group holding a shape with at least two anchors {label}: საჭიროა ჯგუფი, რომელიც შეიცავს ფიგურას მინიმუმ ორი საყრდენი წერტილით +@msg Revolve requires a group holding a shape with at least two anchors ბრუნვა: საჭიროა ჯგუფი, რომელიც შეიცავს ფიგურას მინიმუმ ორი საყრდენი წერტილით +@msg Revolve requires a shape with at least two anchors ბრუნვა: საჭიროა ფიგურა მინიმუმ ორი საყრდენი წერტილით +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} ჯგუფზე: საჭიროა ტრაექტორიები, შედგენილი ტრაექტორიები, ტექსტი, ჯგუფები, მომჭრელი ჯგუფები, გადასვლები და სიმბოლოს ეგზემპლარები; ჯერ გაათავისუფლეთ ან დაშალეთ ეს ელემენტები: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} ჯგუფზე: მაქსიმუმ {MAX_MEMBERS} ობიექტი +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke ჯგუფის ან ფენის 3D ეფექტი თავად ჯგუფზე უნდა იყოს და არა მის შევსებაზე ან კონტურზე +@msg a 3D effect on a group's fill or stroke draws nothing ჯგუფის შევსებაზე ან კონტურზე 3D ეფექტი არაფერს ხატავს +@msg a 3D effect on a group's fill or stroke draws nothing to export ჯგუფის შევსებაზე ან კონტურზე 3D ეფექტი ექსპორტისთვის არაფერს ხატავს +@msg The 3D object is too complex to export 3D ობიექტი ზედმეტად რთულია ექსპორტისთვის +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) ობიექტს არ აქვს 3D ეფექტი (ამოწევა და წაკვეთა, ბრუნვა, გაბერვა ან შემობრუნება) +@msg its 3D effect builds no solid მისი 3D ეფექტი სხეულს არ ქმნის +@msg bevel must be none or one of {_1} bevel უნდა იყოს none ან ერთ-ერთი: {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1}: ამ ნახატიდან სხეულების შექმნა შეუძლებელია: მისი სიმბოლოს ეგზემპლარები, გადასვლები და მომჭრელი ჯგუფები ზედმეტად ბევრ ობიექტს შეიცავს +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label}: სიმბოლოს „{symbol}“ ეგზემპლარიდან სხეულის შექმნა შეუძლებელია: სიმბოლოს ნახატი მიუწვდომელია +@msg {_1}: the clipping path is too complex to cut the art with {_1}: მომჭრელი ტრაექტორია ზედმეტად რთულია ნახატის მოსაჭრელად +@msg {_1} takes at most {MAX_MEMBERS} objects {_1}: მაქსიმუმ {MAX_MEMBERS} ობიექტი +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1}: შეუძლებელია სხეულების შექმნა {MAX_DEPTH} დონეზე ღრმად ჩადგმული ნახატიდან +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient ბრუნვის საღებავი ზედმეტად რთულია ამ ზედაპირისთვის; შეამცირეთ სეგმენტები ან გაამარტივეთ გრადიენტი +@msg Revolve edge must be left or right ბრუნვა: edge უნდა იყოს left ან right +@msg Revolve cap must be a boolean ბრუნვა: cap უნდა იყოს ლოგიკური მნიშვნელობა +@msg the revolved object has no fill or stroke მობრუნებულ ობიექტს არ აქვს შევსება ან კონტური +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor უნდა იყოს გაუმჭვირვალე RGB ფერი #rrggbb ფორმით +@msg lights must contain one to ten lights lights უნდა შეიცავდეს ერთიდან ათამდე სინათლეს +@msg each light must be an object თითოეული სინათლე უნდა იყოს ობიექტი +@msg unknown light setting: use color, intensity, rotation, height, softness or behind სინათლის უცნობი პარამეტრი: გამოიყენეთ color, intensity, rotation, height, softness ან behind +@msg light {key} must be a number from {min} to {max} სინათლე: {key} უნდა იყოს რიცხვი {min}-დან {max}-მდე +@msg light color must use opaque #rrggbb სინათლის color უნდა იყოს გაუმჭვირვალე #rrggbb +@msg light behind must be true or false სინათლის behind უნდა იყოს true ან false +@msg shadow must be true or false shadow უნდა იყოს true ან false +@msg shadowPosition must be behind or below shadowPosition უნდა იყოს behind ან below +@msg {key} must be a number from {min} to {max} {key} უნდა იყოს რიცხვი {min}-დან {max}-მდე +@msg each of `{GRAPHICS}` must be an object `{GRAPHICS}`-ის თითოეული ჩანაწერი უნდა იყოს ობიექტი +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` `{GRAPHICS}`-ის ჩანაწერები იღებს symbol, face, x, y, scale და rotation მნიშვნელობებს და არა `{k}`-ს +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol `{GRAPHICS}`-ის თითოეულ ჩანაწერს სჭირდება `symbol`, სიმბოლოს სახელი +@msg `{GRAPHICS}` face must be front or back `{GRAPHICS}`-ის face უნდა იყოს front ან back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}`-ის {key} უნდა იყოს რიცხვი {lo}-დან {hi}-მდე +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` უნდა იყოს მასივი +@msg Graphics map onto planar faces: use the Extrude or Plane object type გრაფიკა ბრტყელ წახნაგებზე თავსდება: გამოიყენეთ ობიექტის ტიპი „ამოწევა“ ან „სიბრტყე“ +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` იღებს მაქსიმუმ {MAX_GRAPHICS} გრაფიკას +@msg A plane has one face: map its graphics onto the front სიბრტყეს ერთი მხარე აქვს: მისი გრაფიკა წინა მხარეზე განათავსეთ +@msg The graphics are too complex: simplify the symbols' art გრაფიკა ზედმეტად რთულია: გაამარტივეთ სიმბოლოების ნახატი +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art გრაფიკის ნახატი შეიძლება ჩადგმული იყოს მაქსიმუმ {MAX_LEVELS} დონეზე: გაამარტივეთ სიმბოლოს ნახატი +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art გრაფიკის ნახატში სიმბოლოები შეიძლება ჩადგმული იყოს მაქსიმუმ {MAX_NESTING} დონეზე: გაამარტივეთ სიმბოლოს ნახატი +@msg A graphic's art holds too many objects: simplify the symbol's art გრაფიკის ნახატი ზედმეტად ბევრ ობიექტს შეიცავს: გაამარტივეთ სიმბოლოს ნახატი +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art გრაფიკა ხატავს მაქსიმუმ {MAX_PIECES} შევსებას და კონტურს: გაამარტივეთ სიმბოლოს ნახატი +@msg `surface` must be one of {_1} `surface` უნდა იყოს ერთ-ერთი: {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label}: ამ ობიექტისთვის 3D ზედაპირის ნაცვლად საწყისი ნახატი იხატება +@msg {label} draws no faces for this object {label}: ამ ობიექტისთვის არცერთი წახნაგი არ იხატება +@msg the wireframe has more than {MAX_WIRE_EDGES} edges კარკასს {MAX_WIRE_EDGES}-ზე მეტი წიბო აქვს @msg The 3D surface is too complex; simplify the shape 3D ზედაპირი ზედმეტად რთულია; გაამარტივეთ ფიგურა Transform Objects ობიექტების გარდაქმნა @msg unknown parameter `{key}`; did you mean `{near}`? უცნობი პარამეტრი `{key}`; იქნებ `{near}` იგულისხმეთ? diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c46ce6b4fa..5c60e13e2e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5454,6 +5454,65 @@ lighting Light 광원 @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}은(는) 이미지로 입체를 만들 수 없습니다. 먼저 개체 › 이미지 추적 › 만들기로 추적하십시오. @msg {label} requires a shape with at least two anchors {label}에는 고정점이 두 개 이상인 모양이 필요합니다 @msg The shape is too complex for a 3D effect 모양이 너무 복잡하여 3D 효과를 적용할 수 없습니다 +@msg Extrude & Bevel 돌출 및 경사 +@msg Revolve 회전체 +@msg Inflate 부풀리기 +@msg Rotate 회전 +@msg Extrude & Bevel (Classic) 돌출 및 경사(클래식) +@msg Revolve (Classic) 회전체(클래식) +@msg Rotate (Classic) 회전(클래식) +@msg This effect 이 효과 +@msg clipping paths 클리핑 패스 +@msg other objects 기타 개체 +@msg {label} requires a group holding a shape with at least two anchors {label}에는 고정점이 두 개 이상인 모양을 포함한 그룹이 필요합니다 +@msg Revolve requires a group holding a shape with at least two anchors 회전체에는 고정점이 두 개 이상인 모양을 포함한 그룹이 필요합니다 +@msg Revolve requires a shape with at least two anchors 회전체에는 고정점이 두 개 이상인 모양이 필요합니다 +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. 그룹의 {label}에는 패스, 컴파운드 패스, 텍스트, 그룹, 클리핑 그룹, 블렌드, 심볼 인스턴스가 필요합니다. 먼저 다음을 놓거나 확장하십시오: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects 그룹의 {label}은(는) 개체를 최대 {MAX_MEMBERS}개까지 받습니다 +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke 그룹이나 레이어의 3D 효과는 칠이나 획이 아니라 그룹 자체에 적용됩니다 +@msg a 3D effect on a group's fill or stroke draws nothing 그룹의 칠이나 획에 있는 3D 효과는 아무것도 그리지 않습니다 +@msg a 3D effect on a group's fill or stroke draws nothing to export 그룹의 칠이나 획에 있는 3D 효과는 내보낼 것을 그리지 않습니다 +@msg The 3D object is too complex to export 3D 개체가 너무 복잡하여 내보낼 수 없습니다 +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) 개체에 3D 효과(돌출 및 경사, 회전체, 부풀리기 또는 회전)가 없습니다 +@msg its 3D effect builds no solid 3D 효과가 입체를 만들지 않습니다 +@msg bevel must be none or one of {_1} bevel은 none 또는 {_1} 중 하나여야 합니다 +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1}은(는) 이 아트로 입체를 만들 수 없습니다. 심볼 인스턴스, 블렌드, 클리핑 그룹에 개체가 너무 많습니다 +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label}은(는) 심볼 “{symbol}”의 인스턴스로 입체를 만들 수 없습니다. 심볼의 아트를 사용할 수 없습니다 +@msg {_1}: the clipping path is too complex to cut the art with {_1}: 클리핑 패스가 너무 복잡하여 아트를 자를 수 없습니다 +@msg {_1} takes at most {MAX_MEMBERS} objects {_1}은(는) 개체를 최대 {MAX_MEMBERS}개까지 받습니다 +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1}은(는) {MAX_DEPTH}단계보다 깊이 중첩된 아트로 입체를 만들 수 없습니다 +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient 회전체의 칠이 이 표면에 비해 너무 복잡합니다. 세그먼트를 줄이거나 그레이디언트를 단순화하십시오 +@msg Revolve edge must be left or right 회전체: edge는 left 또는 right여야 합니다 +@msg Revolve cap must be a boolean 회전체: cap은 부울 값이어야 합니다 +@msg the revolved object has no fill or stroke 회전체 개체에 칠이나 획이 없습니다 +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor는 #rrggbb 형식의 불투명한 RGB 색상이어야 합니다 +@msg lights must contain one to ten lights lights에는 조명이 1개에서 10개까지 있어야 합니다 +@msg each light must be an object 각 조명은 개체여야 합니다 +@msg unknown light setting: use color, intensity, rotation, height, softness or behind 알 수 없는 조명 설정입니다. color, intensity, rotation, height, softness 또는 behind를 사용하십시오 +@msg light {key} must be a number from {min} to {max} 조명: {key}은(는) {min}에서 {max} 사이의 숫자여야 합니다 +@msg light color must use opaque #rrggbb 조명의 color는 불투명한 #rrggbb여야 합니다 +@msg light behind must be true or false 조명의 behind는 true 또는 false여야 합니다 +@msg shadow must be true or false shadow는 true 또는 false여야 합니다 +@msg shadowPosition must be behind or below shadowPosition은 behind 또는 below여야 합니다 +@msg {key} must be a number from {min} to {max} {key}은(는) {min}에서 {max} 사이의 숫자여야 합니다 +@msg each of `{GRAPHICS}` must be an object `{GRAPHICS}`의 각 항목은 개체여야 합니다 +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` `{GRAPHICS}` 항목에는 symbol, face, x, y, scale, rotation만 사용할 수 있으며 `{k}`은(는) 사용할 수 없습니다 +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol `{GRAPHICS}`의 각 항목에는 심볼 이름인 `symbol`이 필요합니다 +@msg `{GRAPHICS}` face must be front or back `{GRAPHICS}`의 face는 front 또는 back이어야 합니다 +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}`의 {key}은(는) {lo}에서 {hi} 사이의 숫자여야 합니다 +@msg `{GRAPHICS}` must be an array `{GRAPHICS}`은(는) 배열이어야 합니다 +@msg Graphics map onto planar faces: use the Extrude or Plane object type 그래픽은 평평한 면에 매핑됩니다. 돌출 또는 평면 개체 유형을 사용하십시오 +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}`에는 그래픽을 최대 {MAX_GRAPHICS}개까지 넣을 수 있습니다 +@msg A plane has one face: map its graphics onto the front 평면에는 면이 하나뿐입니다. 그래픽을 앞면에 매핑하십시오 +@msg The graphics are too complex: simplify the symbols' art 그래픽이 너무 복잡합니다. 심볼의 아트를 단순화하십시오 +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art 그래픽의 아트는 최대 {MAX_LEVELS}단계까지 중첩할 수 있습니다. 심볼의 아트를 단순화하십시오 +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art 그래픽의 아트는 심볼을 최대 {MAX_NESTING}단계까지 중첩할 수 있습니다. 심볼의 아트를 단순화하십시오 +@msg A graphic's art holds too many objects: simplify the symbol's art 그래픽의 아트에 개체가 너무 많습니다. 심볼의 아트를 단순화하십시오 +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art 그래픽은 칠과 획을 최대 {MAX_PIECES}개까지 그립니다. 심볼의 아트를 단순화하십시오 +@msg `surface` must be one of {_1} `surface`은(는) {_1} 중 하나여야 합니다 +@msg {label} draws the source art for this object instead of its 3D surface {label}은(는) 이 개체에 3D 표면 대신 원본 아트를 그립니다 +@msg {label} draws no faces for this object {label}은(는) 이 개체에 면을 그리지 않습니다 +@msg the wireframe has more than {MAX_WIRE_EDGES} edges 와이어프레임의 가장자리가 {MAX_WIRE_EDGES}개를 넘습니다 @msg The 3D surface is too complex; simplify the shape 3D 표면이 너무 복잡합니다. 모양을 단순화하십시오 Transform Objects 개체 변형 @msg unknown parameter `{key}`; did you mean `{near}`? 알 수 없는 매개변수 `{key}`: `{near}`을(를) 의미하셨습니까? diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a232ac4e8d..6be3afcc74 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5706,6 +5706,65 @@ mark-style Japanese Japońskie @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} nie może utworzyć bryły z obrazu: najpierw obrysuj go poleceniem Obiekt › Obrys obrazu › Utwórz. @msg {label} requires a shape with at least two anchors {label} wymaga kształtu z co najmniej dwoma punktami @msg The shape is too complex for a 3D effect Kształt jest zbyt złożony dla efektu 3D +@msg Extrude & Bevel Wyciągnięcie i fazowanie +@msg Revolve Obrót +@msg Inflate Nadmuchanie +@msg Rotate Obróć +@msg Extrude & Bevel (Classic) Wyciągnięcie i fazowanie (klasyczne) +@msg Revolve (Classic) Obrót (klasyczny) +@msg Rotate (Classic) Obróć (klasyczny) +@msg This effect Ten efekt +@msg clipping paths ścieżki przycinające +@msg other objects inne obiekty +@msg {label} requires a group holding a shape with at least two anchors {label} wymaga grupy zawierającej kształt z co najmniej dwoma punktami +@msg Revolve requires a group holding a shape with at least two anchors Obrót wymaga grupy zawierającej kształt z co najmniej dwoma punktami +@msg Revolve requires a shape with at least two anchors Obrót wymaga kształtu z co najmniej dwoma punktami +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} na grupie wymaga ścieżek, ścieżek złożonych, tekstu, grup, grup przycinających, przejść i wystąpień symboli: najpierw zwolnij lub rozszerz te elementy: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} na grupie przyjmuje najwyżej {MAX_MEMBERS} obiektów +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Efekt 3D na grupie lub warstwie należy nałożyć na samą grupę, a nie na jej wypełnienie lub obrys +@msg a 3D effect on a group's fill or stroke draws nothing efekt 3D na wypełnieniu lub obrysie grupy niczego nie rysuje +@msg a 3D effect on a group's fill or stroke draws nothing to export efekt 3D na wypełnieniu lub obrysie grupy nie rysuje niczego do eksportu +@msg The 3D object is too complex to export Obiekt 3D jest zbyt złożony, aby go wyeksportować +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) obiekt nie ma efektu 3D (Wyciągnięcie i fazowanie, Obrót, Nadmuchanie ani Obróć) +@msg its 3D effect builds no solid jego efekt 3D nie tworzy żadnej bryły +@msg bevel must be none or one of {_1} bevel musi mieć wartość none lub jedną z: {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} nie może utworzyć brył z tego rysunku: jego wystąpienia symboli, przejścia i grupy przycinające zawierają zbyt wiele obiektów +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} nie może utworzyć bryły z wystąpienia symbolu „{symbol}”: rysunek symbolu jest niedostępny +@msg {_1}: the clipping path is too complex to cut the art with {_1}: ścieżka przycinająca jest zbyt złożona, aby przyciąć nią rysunek +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} przyjmuje najwyżej {MAX_MEMBERS} obiektów +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} nie może utworzyć brył z rysunku zagnieżdżonego na więcej niż {MAX_DEPTH} poziomach +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Farba efektu Obrót jest zbyt złożona dla tej powierzchni; zmniejsz Segmenty lub uprość gradient +@msg Revolve edge must be left or right Obrót: edge musi mieć wartość left lub right +@msg Revolve cap must be a boolean Obrót: cap musi być wartością logiczną +@msg the revolved object has no fill or stroke obracany obiekt nie ma wypełnienia ani obrysu +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor musi być nieprzezroczystym kolorem RGB w postaci #rrggbb +@msg lights must contain one to ten lights lights musi zawierać od jednego do dziesięciu świateł +@msg each light must be an object każde światło musi być obiektem +@msg unknown light setting: use color, intensity, rotation, height, softness or behind nieznane ustawienie światła: użyj color, intensity, rotation, height, softness lub behind +@msg light {key} must be a number from {min} to {max} światło: {key} musi być liczbą od {min} do {max} +@msg light color must use opaque #rrggbb color światła musi być nieprzezroczystym kolorem #rrggbb +@msg light behind must be true or false behind światła musi mieć wartość true lub false +@msg shadow must be true or false shadow musi mieć wartość true lub false +@msg shadowPosition must be behind or below shadowPosition musi mieć wartość behind lub below +@msg {key} must be a number from {min} to {max} {key} musi być liczbą od {min} do {max} +@msg each of `{GRAPHICS}` must be an object każdy element `{GRAPHICS}` musi być obiektem +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` elementy `{GRAPHICS}` przyjmują symbol, face, x, y, scale i rotation, a nie `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol każdy element `{GRAPHICS}` wymaga `symbol`, nazwy symbolu +@msg `{GRAPHICS}` face must be front or back face w `{GRAPHICS}` musi mieć wartość front lub back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} w `{GRAPHICS}` musi być liczbą od {lo} do {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` musi być tablicą +@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafiki są nakładane na płaskie ściany: użyj typu obiektu Wyciągnięcie lub Płaszczyzna +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` przyjmuje najwyżej {MAX_GRAPHICS} grafik +@msg A plane has one face: map its graphics onto the front Płaszczyzna ma jedną stronę: umieść jej grafiki na przedniej stronie +@msg The graphics are too complex: simplify the symbols' art Grafiki są zbyt złożone: uprość rysunki symboli +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Rysunek grafiki może być zagnieżdżony najwyżej na {MAX_LEVELS} poziomach: uprość rysunek symbolu +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Rysunek grafiki może zagnieżdżać symbole najwyżej na {MAX_NESTING} poziomach: uprość rysunek symbolu +@msg A graphic's art holds too many objects: simplify the symbol's art Rysunek grafiki zawiera zbyt wiele obiektów: uprość rysunek symbolu +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Grafika rysuje najwyżej {MAX_PIECES} wypełnień i obrysów: uprość rysunek symbolu +@msg `surface` must be one of {_1} `surface` musi mieć jedną z wartości: {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} rysuje dla tego obiektu rysunek źródłowy zamiast jego powierzchni 3D +@msg {label} draws no faces for this object {label} nie rysuje żadnych ścian dla tego obiektu +@msg the wireframe has more than {MAX_WIRE_EDGES} edges model szkieletowy ma więcej niż {MAX_WIRE_EDGES} krawędzi @msg The 3D surface is too complex; simplify the shape Powierzchnia 3D jest zbyt złożona; uprość kształt Add Guides Dodaj linie pomocnicze Total: Razem: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 68a225625d..bd788744df 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5825,6 +5825,65 @@ mark-style Japanese Japonesas @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} não pode criar um sólido a partir de uma imagem: rastreie-a antes com Objeto › Rastreamento de Imagem › Criar. @msg {label} requires a shape with at least two anchors {label} exige uma forma com pelo menos dois pontos de ancoragem @msg The shape is too complex for a 3D effect A forma é complexa demais para um efeito 3D +@msg Extrude & Bevel Extrusão e Bisel +@msg Revolve Revolver +@msg Inflate Inflar +@msg Rotate Girar +@msg Extrude & Bevel (Classic) Extrusão e Bisel (Clássico) +@msg Revolve (Classic) Revolver (Clássico) +@msg Rotate (Classic) Girar (Clássico) +@msg This effect Este efeito +@msg clipping paths caminhos de recorte +@msg other objects outros objetos +@msg {label} requires a group holding a shape with at least two anchors {label} exige um grupo com uma forma com pelo menos dois pontos de ancoragem +@msg Revolve requires a group holding a shape with at least two anchors Revolver exige um grupo com uma forma com pelo menos dois pontos de ancoragem +@msg Revolve requires a shape with at least two anchors Revolver exige uma forma com pelo menos dois pontos de ancoragem +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} em um grupo exige caminhos, caminhos compostos, texto, grupos, grupos de recorte, mesclagens e instâncias de símbolo: primeiro libere ou expanda estes itens: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} em um grupo aceita no máximo {MAX_MEMBERS} objetos +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Um efeito 3D em um grupo ou camada se aplica ao próprio grupo, não ao seu preenchimento ou traçado +@msg a 3D effect on a group's fill or stroke draws nothing um efeito 3D no preenchimento ou traçado de um grupo não desenha nada +@msg a 3D effect on a group's fill or stroke draws nothing to export um efeito 3D no preenchimento ou traçado de um grupo não desenha nada para exportar +@msg The 3D object is too complex to export O objeto 3D é complexo demais para ser exportado +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) o objeto não tem efeito 3D (Extrusão e Bisel, Revolver, Inflar ou Girar) +@msg its 3D effect builds no solid seu efeito 3D não constrói nenhum sólido +@msg bevel must be none or one of {_1} bevel deve ser none ou um de {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} não pode construir sólidos a partir desta arte: suas instâncias de símbolo, mesclagens e grupos de recorte contêm objetos demais +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} não pode construir um sólido a partir da instância do símbolo “{symbol}”: a arte do símbolo não está disponível +@msg {_1}: the clipping path is too complex to cut the art with {_1}: o caminho de recorte é complexo demais para recortar a arte +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} aceita no máximo {MAX_MEMBERS} objetos +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} não pode construir sólidos a partir de arte aninhada em mais de {MAX_DEPTH} níveis +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient A pintura de Revolver é complexa demais para esta superfície; reduza Segmentos ou simplifique o degradê +@msg Revolve edge must be left or right Revolver: edge deve ser left ou right +@msg Revolve cap must be a boolean Revolver: cap deve ser um booleano +@msg the revolved object has no fill or stroke o objeto revolvido não tem preenchimento nem traçado +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor deve ser uma cor RGB opaca no formato #rrggbb +@msg lights must contain one to ten lights lights deve conter de uma a dez luzes +@msg each light must be an object cada luz deve ser um objeto +@msg unknown light setting: use color, intensity, rotation, height, softness or behind configuração de luz desconhecida: use color, intensity, rotation, height, softness ou behind +@msg light {key} must be a number from {min} to {max} luz: {key} deve ser um número de {min} a {max} +@msg light color must use opaque #rrggbb color de uma luz deve usar #rrggbb opaco +@msg light behind must be true or false behind de uma luz deve ser true ou false +@msg shadow must be true or false shadow deve ser true ou false +@msg shadowPosition must be behind or below shadowPosition deve ser behind ou below +@msg {key} must be a number from {min} to {max} {key} deve ser um número de {min} a {max} +@msg each of `{GRAPHICS}` must be an object cada item de `{GRAPHICS}` deve ser um objeto +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` as entradas de `{GRAPHICS}` aceitam symbol, face, x, y, scale e rotation, não `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol cada item de `{GRAPHICS}` precisa de `symbol`, o nome de um símbolo +@msg `{GRAPHICS}` face must be front or back face de `{GRAPHICS}` deve ser front ou back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} de `{GRAPHICS}` deve ser um número de {lo} a {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` deve ser uma matriz +@msg Graphics map onto planar faces: use the Extrude or Plane object type Os gráficos são mapeados em faces planas: use o tipo de objeto Extrusão ou Plano +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` aceita no máximo {MAX_GRAPHICS} gráficos +@msg A plane has one face: map its graphics onto the front Um plano tem uma única face: mapeie seus gráficos na face frontal +@msg The graphics are too complex: simplify the symbols' art Os gráficos são complexos demais: simplifique a arte dos símbolos +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art A arte de um gráfico pode ser aninhada em no máximo {MAX_LEVELS} níveis: simplifique a arte do símbolo +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art A arte de um gráfico pode aninhar símbolos em no máximo {MAX_NESTING} níveis: simplifique a arte do símbolo +@msg A graphic's art holds too many objects: simplify the symbol's art A arte de um gráfico contém objetos demais: simplifique a arte do símbolo +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Um gráfico desenha no máximo {MAX_PIECES} preenchimentos e traçados: simplifique a arte do símbolo +@msg `surface` must be one of {_1} `surface` deve ser um de {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} desenha a arte original deste objeto em vez da sua superfície 3D +@msg {label} draws no faces for this object {label} não desenha nenhuma face para este objeto +@msg the wireframe has more than {MAX_WIRE_EDGES} edges a estrutura de arame tem mais de {MAX_WIRE_EDGES} arestas @msg The 3D surface is too complex; simplify the shape A superfície 3D é complexa demais; simplifique a forma @msg an image of {w} × {h} pixels is too large to draw uma imagem de {w} × {h} pixels é grande demais para ser desenhada @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: não é uma imagem PNG, JPEG, WebP, TIFF ou BMP diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a54d1eed2c..b14417fe77 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5479,6 +5479,65 @@ lighting Light Свет @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}: из изображения нельзя построить тело. Сначала трассируйте его командой Объект › Трассировка изображения › Создать. @msg {label} requires a shape with at least two anchors {label}: требуется фигура не менее чем с двумя опорными точками @msg The shape is too complex for a 3D effect Фигура слишком сложна для 3D-эффекта +@msg Extrude & Bevel Выдавливание и фаска +@msg Revolve Вращение +@msg Inflate Надувание +@msg Rotate Повернуть +@msg Extrude & Bevel (Classic) Выдавливание и фаска (классическое) +@msg Revolve (Classic) Вращение (классическое) +@msg Rotate (Classic) Поворот (классический) +@msg This effect Этот эффект +@msg clipping paths обтравочные контуры +@msg other objects другие объекты +@msg {label} requires a group holding a shape with at least two anchors {label}: требуется группа с фигурой не менее чем с двумя опорными точками +@msg Revolve requires a group holding a shape with at least two anchors Вращение: требуется группа с фигурой не менее чем с двумя опорными точками +@msg Revolve requires a shape with at least two anchors Вращение: требуется фигура не менее чем с двумя опорными точками +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} для группы: нужны контуры, составные контуры, текст, группы, обтравочные группы, переходы и экземпляры символов; сначала отмените или разберите эти элементы: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} для группы: допускается не более {MAX_MEMBERS} объектов +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke 3D-эффект для группы или слоя применяется к самой группе, а не к её заливке или обводке +@msg a 3D effect on a group's fill or stroke draws nothing 3D-эффект на заливке или обводке группы ничего не рисует +@msg a 3D effect on a group's fill or stroke draws nothing to export 3D-эффект на заливке или обводке группы не рисует ничего для экспорта +@msg The 3D object is too complex to export 3D-объект слишком сложен для экспорта +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) у объекта нет 3D-эффекта (Выдавливание и фаска, Вращение, Надувание или Повернуть) +@msg its 3D effect builds no solid его 3D-эффект не строит тела +@msg bevel must be none or one of {_1} bevel должен быть none или одним из: {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1}: нельзя построить тела из этой иллюстрации: её экземпляры символов, переходы и обтравочные группы содержат слишком много объектов +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label}: нельзя построить тело из экземпляра символа «{symbol}»: иллюстрация символа недоступна +@msg {_1}: the clipping path is too complex to cut the art with {_1}: обтравочный контур слишком сложен, чтобы обрезать им иллюстрацию +@msg {_1} takes at most {MAX_MEMBERS} objects {_1}: допускается не более {MAX_MEMBERS} объектов +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1}: нельзя построить тела из иллюстрации с вложенностью более {MAX_DEPTH} уровней +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Окраска «Вращения» слишком сложна для этой поверхности; уменьшите «Сегменты» или упростите градиент +@msg Revolve edge must be left or right Вращение: edge должен быть left или right +@msg Revolve cap must be a boolean Вращение: cap должен быть логическим значением +@msg the revolved object has no fill or stroke у объекта вращения нет заливки или обводки +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor должен быть непрозрачным цветом RGB в формате #rrggbb +@msg lights must contain one to ten lights lights должен содержать от одного до десяти источников света +@msg each light must be an object каждый источник света должен быть объектом +@msg unknown light setting: use color, intensity, rotation, height, softness or behind неизвестный параметр источника света: используйте color, intensity, rotation, height, softness или behind +@msg light {key} must be a number from {min} to {max} источник света: {key} должен быть числом от {min} до {max} +@msg light color must use opaque #rrggbb color источника света должен быть непрозрачным #rrggbb +@msg light behind must be true or false behind источника света должен быть true или false +@msg shadow must be true or false shadow должен быть true или false +@msg shadowPosition must be behind or below shadowPosition должен быть behind или below +@msg {key} must be a number from {min} to {max} {key} должно быть числом от {min} до {max} +@msg each of `{GRAPHICS}` must be an object каждый элемент `{GRAPHICS}` должен быть объектом +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` элементы `{GRAPHICS}` принимают symbol, face, x, y, scale и rotation, а не `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol каждому элементу `{GRAPHICS}` нужен `symbol`, имя символа +@msg `{GRAPHICS}` face must be front or back face в `{GRAPHICS}` должен быть front или back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} в `{GRAPHICS}` должен быть числом от {lo} до {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` должен быть массивом +@msg Graphics map onto planar faces: use the Extrude or Plane object type Графика наносится на плоские грани: используйте тип объекта «Выдавливание» или «Плоскость» +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` принимает не более {MAX_GRAPHICS} графических элементов +@msg A plane has one face: map its graphics onto the front У плоскости одна сторона: размещайте её графику на передней стороне +@msg The graphics are too complex: simplify the symbols' art Графика слишком сложна: упростите иллюстрации символов +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Иллюстрация графики может быть вложена не более чем на {MAX_LEVELS} уровней: упростите иллюстрацию символа +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Иллюстрация графики может вкладывать символы не более чем на {MAX_NESTING} уровней: упростите иллюстрацию символа +@msg A graphic's art holds too many objects: simplify the symbol's art Иллюстрация графики содержит слишком много объектов: упростите иллюстрацию символа +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Графика рисует не более {MAX_PIECES} заливок и обводок: упростите иллюстрацию символа +@msg `surface` must be one of {_1} `surface` должно быть одним из: {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label}: для этого объекта рисуется исходная иллюстрация вместо его 3D-поверхности +@msg {label} draws no faces for this object {label}: для этого объекта не рисуется ни одной грани +@msg the wireframe has more than {MAX_WIRE_EDGES} edges каркас содержит более {MAX_WIRE_EDGES} рёбер @msg The 3D surface is too complex; simplify the shape 3D-поверхность слишком сложна; упростите фигуру Transform Objects Трансформировать объекты @msg unknown parameter `{key}`; did you mean `{near}`? неизвестный параметр `{key}`; возможно, имелся в виду `{near}`? diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index d0648b5946..a4c8e660e4 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5347,6 +5347,65 @@ lighting Light Ljus @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} kan inte skapa en solid av en bild: kalkera den först med Objekt › Bildkalkering › Skapa. @msg {label} requires a shape with at least two anchors {label} kräver en form med minst två ankarpunkter @msg The shape is too complex for a 3D effect Formen är för komplex för en 3D-effekt +@msg Extrude & Bevel Extrudera och fasa +@msg Revolve Rotera runt axel +@msg Inflate Blås upp +@msg Rotate Rotera +@msg Extrude & Bevel (Classic) Extrudera och fasa (klassisk) +@msg Revolve (Classic) Rotera runt axel (klassisk) +@msg Rotate (Classic) Rotera (klassisk) +@msg This effect Den här effekten +@msg clipping paths urklippsbanor +@msg other objects andra objekt +@msg {label} requires a group holding a shape with at least two anchors {label} kräver en grupp med en form med minst två ankarpunkter +@msg Revolve requires a group holding a shape with at least two anchors Rotera runt axel kräver en grupp med en form med minst två ankarpunkter +@msg Revolve requires a shape with at least two anchors Rotera runt axel kräver en form med minst två ankarpunkter +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} på en grupp kräver banor, sammansatta banor, text, grupper, urklippsgrupper, blandningar och symbolinstanser: frigör eller expandera först följande: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} på en grupp tar högst {MAX_MEMBERS} objekt +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke En 3D-effekt på en grupp eller ett lager hör till själva gruppen, inte till dess fyllning eller linje +@msg a 3D effect on a group's fill or stroke draws nothing en 3D-effekt på en grupps fyllning eller linje ritar ingenting +@msg a 3D effect on a group's fill or stroke draws nothing to export en 3D-effekt på en grupps fyllning eller linje ritar ingenting att exportera +@msg The 3D object is too complex to export 3D-objektet är för komplext för att exporteras +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) objektet har ingen 3D-effekt (Extrudera och fasa, Rotera runt axel, Blås upp eller Rotera) +@msg its 3D effect builds no solid dess 3D-effekt bygger ingen solid +@msg bevel must be none or one of {_1} bevel måste vara none eller något av {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} kan inte bygga solider av den här teckningen: dess symbolinstanser, blandningar och urklippsgrupper innehåller för många objekt +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} kan inte bygga en solid av instansen av symbolen ”{symbol}”: symbolens teckning är inte tillgänglig +@msg {_1}: the clipping path is too complex to cut the art with {_1}: urklippsbanan är för komplex för att klippa teckningen med +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} tar högst {MAX_MEMBERS} objekt +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} kan inte bygga solider av teckning som är kapslad mer än {MAX_DEPTH} nivåer djupt +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Färgen för Rotera runt axel är för komplex för den här ytan; minska Segment eller förenkla övertoningen +@msg Revolve edge must be left or right Rotera runt axel: edge måste vara left eller right +@msg Revolve cap must be a boolean Rotera runt axel: cap måste vara ett booleskt värde +@msg the revolved object has no fill or stroke det roterade objektet har ingen fyllning eller linje +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor måste vara en ogenomskinlig RGB-färg i formen #rrggbb +@msg lights must contain one to ten lights lights måste innehålla ett till tio ljus +@msg each light must be an object varje ljus måste vara ett objekt +@msg unknown light setting: use color, intensity, rotation, height, softness or behind okänd ljusinställning: använd color, intensity, rotation, height, softness eller behind +@msg light {key} must be a number from {min} to {max} ljus: {key} måste vara ett tal från {min} till {max} +@msg light color must use opaque #rrggbb color för ett ljus måste vara en ogenomskinlig #rrggbb +@msg light behind must be true or false behind för ett ljus måste vara true eller false +@msg shadow must be true or false shadow måste vara true eller false +@msg shadowPosition must be behind or below shadowPosition måste vara behind eller below +@msg {key} must be a number from {min} to {max} {key} måste vara ett tal från {min} till {max} +@msg each of `{GRAPHICS}` must be an object varje post i `{GRAPHICS}` måste vara ett objekt +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` posterna i `{GRAPHICS}` tar symbol, face, x, y, scale och rotation, inte `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol varje post i `{GRAPHICS}` behöver `symbol`, namnet på en symbol +@msg `{GRAPHICS}` face must be front or back face i `{GRAPHICS}` måste vara front eller back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} i `{GRAPHICS}` måste vara ett tal från {lo} till {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` måste vara en matris +@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafik placeras på plana sidor: använd objekttypen Extrudera eller Plan +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` tar högst {MAX_GRAPHICS} grafiker +@msg A plane has one face: map its graphics onto the front Ett plan har en sida: placera dess grafik på framsidan +@msg The graphics are too complex: simplify the symbols' art Grafiken är för komplex: förenkla symbolernas teckning +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art En grafiks teckning får vara kapslad högst {MAX_LEVELS} nivåer djupt: förenkla symbolens teckning +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art En grafiks teckning får kapsla symboler högst {MAX_NESTING} nivåer djupt: förenkla symbolens teckning +@msg A graphic's art holds too many objects: simplify the symbol's art En grafiks teckning innehåller för många objekt: förenkla symbolens teckning +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art En grafik ritar högst {MAX_PIECES} fyllningar och linjer: förenkla symbolens teckning +@msg `surface` must be one of {_1} `surface` måste vara något av {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} ritar källteckningen för det här objektet i stället för dess 3D-yta +@msg {label} draws no faces for this object {label} ritar inga sidor för det här objektet +@msg the wireframe has more than {MAX_WIRE_EDGES} edges trådramen har fler än {MAX_WIRE_EDGES} kanter @msg The 3D surface is too complex; simplify the shape 3D-ytan är för komplex; förenkla formen Transform Objects Transformera objekt @msg unknown parameter `{key}`; did you mean `{near}`? okänd parameter `{key}`; menade du `{near}`? diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 887eb4253b..4fba2eb673 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1896,6 +1896,142 @@ fn plugin_error_reasons_are_translated_with_the_error() { assert!(message(de(), &errors[3].0).ends_with("objects[0]: `opacity` muss eine Zahl sein"), "{}", message(de(), &errors[3].0)); } +/// The 3D effects' source files under `crates/effects/src`. Their refusals reach the status bar +/// through the effect commands, and every `format!` text in them outside tests is a refusal. +const THREE_D_SOURCES: &[&str] = &[ + "solids.rs", + "solids/classic.rs", + "solids/graphics.rs", + "solids/shadow.rs", + "solids/source.rs", + "solids/wireframe.rs", + "revolve.rs", + "revolve/classic.rs", + "lighting.rs", +]; + +/// The values 3D refusals put in their templates that are messages of their own: the name of an +/// effect that has no 3D type of its own, and the objects a group's 3D effect can't build from. +const THREE_D_PARTS: &[&str] = &["This effect", "clipping paths", "other objects"]; + +/// The 3D effects' refusals as catalog templates: the messages of [`THREE_D_SOURCES`] that +/// [`source_messages`] finds, and the `format!` texts their helpers return. +fn three_d_refusals() -> std::collections::BTreeSet { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../effects/src"); + let texts: Vec = THREE_D_SOURCES.iter().map(|f| std::fs::read_to_string(dir.join(f)).expect("a 3D source file")).collect(); + let mut consts = std::collections::BTreeMap::new(); + for text in &texts { + for (name, value) in str_consts(without_test_module(text)) { + consts.entry(name).or_insert_with(Vec::new).push(value); + } + } + texts.iter().flat_map(|text| source_messages(without_test_module(text), &[("format!(", 0)], &consts)).collect() +} + +/// The names 3D refusals give their effect (`{label}`): each 3D effect's menu label without its +/// ellipsis, and [`THREE_D_PARTS`]. +fn three_d_parts() -> Vec { + let labels = vectorcraft_effects::effect_catalog().into_iter().filter(|e| vectorcraft_effects::is_three_d(e.id)); + labels.map(|e| e.label.trim_end_matches('…').to_string()).chain(THREE_D_PARTS.iter().map(|p| p.to_string())).collect() +} + +/// The 3D effects' refusals, and the effect names and objects they name, have an `@msg` row in +/// every complete-message catalog. +#[test] +fn three_d_refusals_are_translated_in_every_complete_catalog() { + let refusals = three_d_refusals(); + assert!(refusals.len() > 55, "scan found only {} 3D refusals", refusals.len()); + let helper_made = [ + "{label} can't build a solid from an image: trace it first with Object › Image Trace › Make.", + "{_1} can't build solids from art nested more than {MAX_DEPTH} levels deep", + "the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)", + ]; + for m in helper_made { + assert!(refusals.contains(m), "the scan reads helpers' texts and constants: {m}"); + } + let parts = three_d_parts(); + for label in ["Extrude & Bevel", "Revolve", "Inflate", "Rotate", "Extrude & Bevel (Classic)", "Revolve (Classic)", "Rotate (Classic)"] { + assert!(parts.iter().any(|p| p == label), "{label} is a 3D effect's name"); + } + let mut missing = vec![]; + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let (entries, _) = parse_entries(lang.0.source); + let rows: std::collections::BTreeSet<&str> = entries.iter().filter(|(ctx, _, _)| ctx == "@msg").map(|(_, src, _)| src.as_str()).collect(); + missing.extend(refusals.iter().chain(&parts).filter(|m| !rows.contains(m.as_str())).map(|m| format!("{code}: {m}"))); + } + assert!(missing.is_empty(), "{} 3D messages have no @msg row:\n{}", missing.len(), missing.join("\n")); +} + +/// A 3D effect's refusal shows in the interface language whole, with the effect's translated +/// name, and with its numbers, the parameter keys it names and a symbol's name as given; the +/// error the command returns stays English. +#[test] +fn three_d_refusals_translate_with_their_values() { + use serde_json::json; + use std::sync::Arc; + use vectorcraft_doc::{Node, NodeId, NodeKind}; + use vectorcraft_effects::{CLASSIC_EXTRUDE, EXTRUDE}; + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let id = s.execute("path.create", &json!({"d": "M10 10L60 10L60 60L10 60Z"})).unwrap()["id"].as_u64().unwrap(); + let rect = s.active().unwrap().doc.node(NodeId(id)).unwrap().clone(); + let group = Node::group(NodeId(id + 1), vec![Arc::new(rect.clone())]); + let instance = Node::new(NodeId(id + 2), NodeKind::SymbolInstance { symbol: "Mine 7".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let refuse = |r: Result<(), String>| { + let e = r.expect_err("refused"); + vectorcraft_engine::EngineError::BadParams { cmd: "effect.apply".into(), msg: e }.to_string() + }; + let errors = [ + (refuse(vectorcraft_effects::validate_three_d_at(&group, EXTRUDE, &json!({}), Some((0, 0)))), "goes on the group itself", vec![]), + ( + refuse(vectorcraft_effects::validate_three_d(&rect, EXTRUDE, &json!({"lights": [{"intensity": 500}]}))), + "must be a number from", + vec!["0", "100"], + ), + ( + refuse(vectorcraft_effects::validate_three_d(&rect, EXTRUDE, &json!({"graphics": [{"symbol": "S", "face": "side"}]}))), + "must be front or back", + vec!["`graphics`", "front", "back"], + ), + (refuse(vectorcraft_effects::validate_three_d(&rect, CLASSIC_EXTRUDE, &json!({"depth": "deep"}))), "must be a number", vec!["`depth`"]), + (refuse(vectorcraft_effects::validate_three_d(&instance, EXTRUDE, &json!({}))), "the symbol's art isn't available", vec!["Mine 7"]), + (refuse(vectorcraft_effects::three_d_wireframe_in(None, &rect).map(drop)), "has no 3D effect", vec![]), + ]; + for (e, english, _) in &errors { + assert!(e.starts_with("invalid parameters for `effect.apply`: ") && e.contains(english), "{e}"); + assert_eq!(message(Lang::EN, e), *e); + } + assert!(errors[1].0.contains("light intensity must be a number from 0 to 100"), "{}", errors[1].0); + assert!(errors[4].0.contains("Extrude & Bevel can't build a solid from the instance of symbol \"Mine 7\""), "{}", errors[4].0); + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + for (e, english, kept) in &errors { + let shown = message(lang, e); + assert!(!shown.contains(english) && shown.contains("`effect.apply`"), "{code}: {e} -> {shown}"); + assert!(kept.iter().all(|k| shown.contains(k)), "{code}: {e} -> {shown}"); + } + let symbol = message(lang, &errors[4].0); + let label = message(lang, "Extrude & Bevel"); + assert!(label != "Extrude & Bevel" && symbol.contains(&label), "{code}: the effect's name is translated: {symbol}"); + } + assert_eq!(message(de(), &errors[1].0), "ungültige Parameter für `effect.apply`: Licht: intensity muss eine Zahl von 0 bis 100 sein"); +} + +/// A value a message quotes is a name and is shown as written, even when a catalog row translates +/// the same text: a symbol called Rotate stays Rotate while the effect's name is translated. +#[test] +fn quoted_values_in_a_message_stay_as_written() { + let s = "Extrude & Bevel can't build a solid from the instance of symbol \"Rotate\": the symbol's art isn't available"; + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let shown = message(lang, s); + let rotate = message(lang, "Rotate"); + assert!(rotate != "Rotate" && shown.contains("Rotate") && !shown.contains(&rotate), "{code}: {shown}"); + assert!(shown.contains(&message(lang, "Extrude & Bevel")), "{code}: {shown}"); + } +} + /// The Gamut Check status line names a count of 1 in the singular and other counts in the plural, /// and every complete catalog translates it, in the form its plural rule picks. #[test] @@ -3108,6 +3244,7 @@ const NAMED_KEYS: &[(&str, &[&str])] = &[ ("axis must be \"vertical\", \"horizontal\" or an angle in degrees, not `{v}`", &["axis"]), ("balance must be between 0 and 100", &["balance"]), ("before must name a panel", &["before"]), + ("bevel must be none or one of {_1}", &["bevel", "none"]), ("brightness must be dark|mediumDark|mediumLight|light", &["brightness", "dark", "mediumDark", "mediumLight", "light"]), ("clip must be true or false", &["clip"]), ("collapsed must be true or false", &["collapsed"]), @@ -3127,6 +3264,10 @@ const NAMED_KEYS: &[(&str, &[&str])] = &[ ("kerning must be a number, \"auto\", or \"optical\"", &["kerning"]), ("lang must be one of {_1}", &["lang"]), ("layers must be true or false", &["layers"]), + ("light behind must be true or false", &["behind"]), + ("light color must use opaque #rrggbb", &["color"]), + ("lightColor must be an opaque RGB color in #rrggbb form", &["lightColor"]), + ("lights must contain one to ten lights", &["lights"]), ("member is for order front, forward, backward or back", &["member", "order", "front", "forward", "backward", "back"]), ("mode is set, add or toggle, not `{other}`", &["mode", "set", "add", "toggle"]), ("names is empty", &["names"]), @@ -3141,18 +3282,28 @@ const NAMED_KEYS: &[(&str, &[&str])] = &[ ("plane must be left, right or ground", &["plane", "left", "right", "ground"]), ("precision must be from 0.001 to 10 pt", &["precision"]), ("rect is [x, y, width, height] in points", &["rect"]), + ("Revolve cap must be a boolean", &["cap"]), + ("Revolve edge must be left or right", &["edge", "left", "right"]), ("scale must be a number from {_1} to {_2}, not {_3}", &["scale"]), ("scale must be a number from {_1} to {_2}, not {s}", &["scale"]), + ("shadow must be true or false", &["shadow"]), + ("shadowPosition must be behind or below", &["shadowPosition", "behind", "below"]), ("show must be true or false", &["show"]), ("span: \"selection\" places a linear, radial, conic or diamond gradient, not a freeform one", &["span"]), ("thickness must be from 0.01 to 5000 pt", &["thickness"]), ("title can't be empty", &["title"]), + ( + "unknown light setting: use color, intensity, rotation, height, softness or behind", + &["color", "intensity", "rotation", "height", "softness", "behind"], + ), ("view is required", &["view"]), ("width must be positive", &["width"]), ("zone must be a string", &["zone"]), ("zone must be tab, center, left, right, top or bottom", &["zone", "tab", "center", "left", "right", "top", "bottom"]), ("`dither` applies to gradient fills (stroke: false)", &["stroke"]), ("`strokeMode` applies to strokes (stroke: true)", &["stroke"]), + ("`{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}`", &["symbol", "face", "x", "y", "scale", "rotation"]), + ("`{GRAPHICS}` face must be front or back", &["face", "front", "back"]), ]; /// Every catalog's translation of a [`NAMED_KEYS`] message keeps each name the table lists, as diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 915a244b43..da1aac3751 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5142,6 +5142,65 @@ lighting Light Işık @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} bir görüntüden katı cisim oluşturamaz: önce Nesne › Görüntü İzleme › Yap ile izleyin. @msg {label} requires a shape with at least two anchors {label} en az iki bağlantı noktasına sahip bir şekil gerektirir @msg The shape is too complex for a 3D effect Şekli 3 boyutlu bir efekt için fazla karmaşık +@msg Extrude & Bevel Ekstrüzyon ve Eğim +@msg Revolve Döndür +@msg Inflate Şişir +@msg Rotate Döndür +@msg Extrude & Bevel (Classic) Ekstrüzyon ve Eğim (Klasik) +@msg Revolve (Classic) Döndür (Klasik) +@msg Rotate (Classic) Çevir (Klasik) +@msg This effect Bu efekt +@msg clipping paths kırpma yolları +@msg other objects diğer nesneler +@msg {label} requires a group holding a shape with at least two anchors {label}, en az iki bağlantı noktasına sahip bir şekil içeren bir grup gerektirir +@msg Revolve requires a group holding a shape with at least two anchors Döndür, en az iki bağlantı noktasına sahip bir şekil içeren bir grup gerektirir +@msg Revolve requires a shape with at least two anchors Döndür, en az iki bağlantı noktasına sahip bir şekil gerektirir +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. Bir gruptaki {label}; yollar, bileşik yollar, metin, gruplar, kırpma grupları, karışımlar ve sembol örnekleri gerektirir: önce şunları serbest bırak veya genişlet: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects Bir gruptaki {label} en fazla {MAX_MEMBERS} nesne alır +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Bir grup veya katmandaki 3 boyutlu efekt, dolgusuna veya konturuna değil, grubun kendisine uygulanır +@msg a 3D effect on a group's fill or stroke draws nothing bir grubun dolgusundaki veya konturundaki 3 boyutlu efekt hiçbir şey çizmez +@msg a 3D effect on a group's fill or stroke draws nothing to export bir grubun dolgusundaki veya konturundaki 3 boyutlu efekt dışa aktarılacak hiçbir şey çizmez +@msg The 3D object is too complex to export 3 boyutlu nesne dışa aktarmak için çok karmaşık +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) nesnenin 3 boyutlu efekti yok (Ekstrüzyon ve Eğim, Döndür, Şişir veya Döndür) +@msg its 3D effect builds no solid 3 boyutlu efekti hiçbir katı oluşturmuyor +@msg bevel must be none or one of {_1} bevel, none veya {_1} değerlerinden biri olmalıdır +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} bu resimden katı oluşturamaz: sembol örnekleri, karışımları ve kırpma grupları çok fazla nesne içeriyor +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label}, “{symbol}” sembolünün örneğinden katı oluşturamaz: sembolün resmi kullanılamıyor +@msg {_1}: the clipping path is too complex to cut the art with {_1}: kırpma yolu resmi kesmek için çok karmaşık +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} en fazla {MAX_MEMBERS} nesne alır +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1}, {MAX_DEPTH} düzeyden daha derin iç içe geçmiş resimden katı oluşturamaz +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Döndür boyası bu yüzey için çok karmaşık; Segmentler değerini azalt veya degradeyi basitleştir +@msg Revolve edge must be left or right Döndür: edge, left veya right olmalıdır +@msg Revolve cap must be a boolean Döndür: cap bir boole değeri olmalıdır +@msg the revolved object has no fill or stroke döndürülen nesnenin dolgusu veya konturu yok +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor, #rrggbb biçiminde opak bir RGB rengi olmalıdır +@msg lights must contain one to ten lights lights bir ile on arasında ışık içermelidir +@msg each light must be an object her ışık bir nesne olmalıdır +@msg unknown light setting: use color, intensity, rotation, height, softness or behind bilinmeyen ışık ayarı: color, intensity, rotation, height, softness veya behind kullan +@msg light {key} must be a number from {min} to {max} ışık: {key} değeri {min} ile {max} arasında bir sayı olmalıdır +@msg light color must use opaque #rrggbb ışığın color değeri opak #rrggbb olmalıdır +@msg light behind must be true or false ışığın behind değeri true veya false olmalıdır +@msg shadow must be true or false shadow, true veya false olmalıdır +@msg shadowPosition must be behind or below shadowPosition, behind veya below olmalıdır +@msg {key} must be a number from {min} to {max} {key} değeri {min} ile {max} arasında bir sayı olmalıdır +@msg each of `{GRAPHICS}` must be an object `{GRAPHICS}` içindeki her öğe bir nesne olmalıdır +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` `{GRAPHICS}` girdileri symbol, face, x, y, scale ve rotation alır, `{k}` almaz +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol `{GRAPHICS}` içindeki her öğe, bir sembolün adı olan `symbol` gerektirir +@msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` face değeri front veya back olmalıdır +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` {key} değeri {lo} ile {hi} arasında bir sayı olmalıdır +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` bir dizi olmalıdır +@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafikler düz yüzlere eşlenir: Ekstrüzyon veya Düzlem nesne türünü kullan +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` en fazla {MAX_GRAPHICS} grafik alır +@msg A plane has one face: map its graphics onto the front Bir düzlemin tek yüzü vardır: grafiklerini ön yüze eşle +@msg The graphics are too complex: simplify the symbols' art Grafikler çok karmaşık: sembollerin resmini basitleştir +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Bir grafiğin resmi en fazla {MAX_LEVELS} düzey iç içe olabilir: sembolün resmini basitleştir +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Bir grafiğin resmi sembolleri en fazla {MAX_NESTING} düzey iç içe yerleştirebilir: sembolün resmini basitleştir +@msg A graphic's art holds too many objects: simplify the symbol's art Bir grafiğin resmi çok fazla nesne içeriyor: sembolün resmini basitleştir +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Bir grafik en fazla {MAX_PIECES} dolgu ve kontur çizer: sembolün resmini basitleştir +@msg `surface` must be one of {_1} `surface`, {_1} değerlerinden biri olmalıdır +@msg {label} draws the source art for this object instead of its 3D surface {label} bu nesne için 3 boyutlu yüzeyi yerine kaynak resmi çizer +@msg {label} draws no faces for this object {label} bu nesne için hiçbir yüz çizmez +@msg the wireframe has more than {MAX_WIRE_EDGES} edges tel kafeste {MAX_WIRE_EDGES} kenardan fazla var @msg The 3D surface is too complex; simplify the shape 3 boyutlu yüzey çok karmaşık; şekli basitleştir Transform Objects Nesneleri Dönüştür @msg unknown parameter `{key}`; did you mean `{near}`? bilinmeyen parametre `{key}`; `{near}` mi demek istediniz? diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e8b5445a50..32293c863d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5473,6 +5473,65 @@ lighting Light Світло @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}: із зображення не можна побудувати тіло. Спершу трасуйте його командою Об’єкт › Трасування зображення › Створити. @msg {label} requires a shape with at least two anchors {label}: потрібна фігура щонайменше з двома опорними точками @msg The shape is too complex for a 3D effect Фігура надто складна для 3D-ефекту +@msg Extrude & Bevel Видавлювання та фаска +@msg Revolve Обертання +@msg Inflate Надування +@msg Rotate Повернути +@msg Extrude & Bevel (Classic) Видавлювання та фаска (класичне) +@msg Revolve (Classic) Обертання (класичне) +@msg Rotate (Classic) Поворот (класичний) +@msg This effect Цей ефект +@msg clipping paths відсічні контури +@msg other objects інші об’єкти +@msg {label} requires a group holding a shape with at least two anchors {label}: потрібна група з фігурою щонайменше з двома опорними точками +@msg Revolve requires a group holding a shape with at least two anchors Обертання: потрібна група з фігурою щонайменше з двома опорними точками +@msg Revolve requires a shape with at least two anchors Обертання: потрібна фігура щонайменше з двома опорними точками +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} для групи: потрібні контури, складені контури, текст, групи, відсічні групи, переходи та екземпляри символів; спершу звільніть або розберіть ці елементи: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} для групи: допускається не більше {MAX_MEMBERS} об’єктів +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke 3D-ефект для групи або шару застосовується до самої групи, а не до її заливки чи обведення +@msg a 3D effect on a group's fill or stroke draws nothing 3D-ефект на заливці або обведенні групи нічого не малює +@msg a 3D effect on a group's fill or stroke draws nothing to export 3D-ефект на заливці або обведенні групи не малює нічого для експорту +@msg The 3D object is too complex to export 3D-об’єкт надто складний для експорту +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) об’єкт не має 3D-ефекту (Видавлювання та фаска, Обертання, Надування або Повернути) +@msg its 3D effect builds no solid його 3D-ефект не будує тіла +@msg bevel must be none or one of {_1} bevel має бути none або одним з: {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1}: не вдається побудувати тіла з цієї ілюстрації: її екземпляри символів, переходи та відсічні групи містять забагато об’єктів +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label}: не вдається побудувати тіло з екземпляра символу «{symbol}»: ілюстрація символу недоступна +@msg {_1}: the clipping path is too complex to cut the art with {_1}: відсічний контур надто складний, щоб обрізати ним ілюстрацію +@msg {_1} takes at most {MAX_MEMBERS} objects {_1}: допускається не більше {MAX_MEMBERS} об’єктів +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1}: не вдається побудувати тіла з ілюстрації з вкладеністю понад {MAX_DEPTH} рівнів +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Фарба «Обертання» надто складна для цієї поверхні; зменште «Сегменти» або спростіть градієнт +@msg Revolve edge must be left or right Обертання: edge має бути left або right +@msg Revolve cap must be a boolean Обертання: cap має бути логічним значенням +@msg the revolved object has no fill or stroke об’єкт обертання не має заливки чи обведення +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor має бути непрозорим кольором RGB у форматі #rrggbb +@msg lights must contain one to ten lights lights має містити від одного до десяти джерел світла +@msg each light must be an object кожне джерело світла має бути об’єктом +@msg unknown light setting: use color, intensity, rotation, height, softness or behind невідомий параметр джерела світла: використайте color, intensity, rotation, height, softness або behind +@msg light {key} must be a number from {min} to {max} джерело світла: {key} має бути числом від {min} до {max} +@msg light color must use opaque #rrggbb color джерела світла має бути непрозорим #rrggbb +@msg light behind must be true or false behind джерела світла має бути true або false +@msg shadow must be true or false shadow має бути true або false +@msg shadowPosition must be behind or below shadowPosition має бути behind або below +@msg {key} must be a number from {min} to {max} {key} має бути числом від {min} до {max} +@msg each of `{GRAPHICS}` must be an object кожен елемент `{GRAPHICS}` має бути об’єктом +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` елементи `{GRAPHICS}` приймають symbol, face, x, y, scale і rotation, а не `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol кожному елементу `{GRAPHICS}` потрібен `symbol`, назва символу +@msg `{GRAPHICS}` face must be front or back face у `{GRAPHICS}` має бути front або back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} у `{GRAPHICS}` має бути числом від {lo} до {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` має бути масивом +@msg Graphics map onto planar faces: use the Extrude or Plane object type Графіка наноситься на плоскі грані: використайте тип об’єкта «Видавлювання» або «Площина» +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` приймає не більше {MAX_GRAPHICS} графічних елементів +@msg A plane has one face: map its graphics onto the front Площина має одну сторону: розміщуйте її графіку на передній стороні +@msg The graphics are too complex: simplify the symbols' art Графіка надто складна: спростіть ілюстрації символів +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Ілюстрація графіки може бути вкладена не більше ніж на {MAX_LEVELS} рівнів: спростіть ілюстрацію символу +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Ілюстрація графіки може вкладати символи не більше ніж на {MAX_NESTING} рівнів: спростіть ілюстрацію символу +@msg A graphic's art holds too many objects: simplify the symbol's art Ілюстрація графіки містить забагато об’єктів: спростіть ілюстрацію символу +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Графіка малює не більше {MAX_PIECES} заливок і обведень: спростіть ілюстрацію символу +@msg `surface` must be one of {_1} `surface` має бути одним з: {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label}: для цього об’єкта малюється вихідна ілюстрація замість його 3D-поверхні +@msg {label} draws no faces for this object {label}: для цього об’єкта не малюється жодної грані +@msg the wireframe has more than {MAX_WIRE_EDGES} edges каркас має понад {MAX_WIRE_EDGES} ребер @msg The 3D surface is too complex; simplify the shape 3D-поверхня надто складна; спростіть фігуру Transform Objects Трансформувати об’єкти @msg unknown parameter `{key}`; did you mean `{near}`? невідомий параметр `{key}`; можливо, ви мали на увазі `{near}`? diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 51681bfed5..689e4023f7 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5260,6 +5260,65 @@ theme Appearance Giao diện @msg Targa files are RGB: colorModel rgb Tệp Targa là RGB: colorModel rgb @msg Text Wrap: select a wrap object Bọc chữ: hãy chọn đối tượng bọc @msg that document is no longer open tài liệu đó không còn mở +@msg Extrude & Bevel Kéo dài & vát +@msg Revolve Xoay tròn +@msg Inflate Phình +@msg Rotate Xoay +@msg Extrude & Bevel (Classic) Kéo dài & vát (cổ điển) +@msg Revolve (Classic) Xoay tròn (cổ điển) +@msg Rotate (Classic) Xoay (cổ điển) +@msg This effect Hiệu ứng này +@msg clipping paths đường dẫn cắt +@msg other objects các đối tượng khác +@msg {label} requires a group holding a shape with at least two anchors {label} yêu cầu một nhóm chứa hình dạng có ít nhất hai điểm neo +@msg Revolve requires a group holding a shape with at least two anchors Xoay tròn yêu cầu một nhóm chứa hình dạng có ít nhất hai điểm neo +@msg Revolve requires a shape with at least two anchors Xoay tròn yêu cầu một hình dạng có ít nhất hai điểm neo +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} trên nhóm cần đường dẫn, đường dẫn phức, văn bản, nhóm, nhóm cắt, hòa trộn và phiên bản ký hiệu: hãy nhả hoặc mở rộng các mục sau trước: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} trên nhóm nhận tối đa {MAX_MEMBERS} đối tượng +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Hiệu ứng 3D trên nhóm hoặc lớp được đặt trên chính nhóm, không phải trên phần tô hoặc nét của nó +@msg a 3D effect on a group's fill or stroke draws nothing hiệu ứng 3D trên phần tô hoặc nét của nhóm không vẽ gì +@msg a 3D effect on a group's fill or stroke draws nothing to export hiệu ứng 3D trên phần tô hoặc nét của nhóm không vẽ gì để xuất +@msg The 3D object is too complex to export Đối tượng 3D quá phức tạp để xuất +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) đối tượng không có hiệu ứng 3D (Kéo dài & vát, Xoay tròn, Phình hoặc Xoay) +@msg its 3D effect builds no solid hiệu ứng 3D của nó không tạo ra khối nào +@msg bevel must be none or one of {_1} bevel phải là none hoặc một trong {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} không thể tạo khối từ hình vẽ này: các phiên bản ký hiệu, hòa trộn và nhóm cắt của nó chứa quá nhiều đối tượng +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} không thể tạo khối từ phiên bản của ký hiệu “{symbol}”: không có hình vẽ của ký hiệu +@msg {_1}: the clipping path is too complex to cut the art with {_1}: đường dẫn cắt quá phức tạp để cắt hình vẽ +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} nhận tối đa {MAX_MEMBERS} đối tượng +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} không thể tạo khối từ hình vẽ lồng sâu hơn {MAX_DEPTH} cấp +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Màu tô của Xoay tròn quá phức tạp cho bề mặt này; hãy giảm Đoạn hoặc đơn giản hóa chuyển sắc +@msg Revolve edge must be left or right Xoay tròn: edge phải là left hoặc right +@msg Revolve cap must be a boolean Xoay tròn: cap phải là giá trị boolean +@msg the revolved object has no fill or stroke đối tượng xoay tròn không có phần tô hoặc nét +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor phải là màu RGB không trong suốt dạng #rrggbb +@msg lights must contain one to ten lights lights phải chứa từ một đến mười nguồn sáng +@msg each light must be an object mỗi nguồn sáng phải là một đối tượng +@msg unknown light setting: use color, intensity, rotation, height, softness or behind thiết lập nguồn sáng không xác định: hãy dùng color, intensity, rotation, height, softness hoặc behind +@msg light {key} must be a number from {min} to {max} nguồn sáng: {key} phải là số từ {min} đến {max} +@msg light color must use opaque #rrggbb color của nguồn sáng phải là #rrggbb không trong suốt +@msg light behind must be true or false behind của nguồn sáng phải là true hoặc false +@msg shadow must be true or false shadow phải là true hoặc false +@msg shadowPosition must be behind or below shadowPosition phải là behind hoặc below +@msg {key} must be a number from {min} to {max} {key} phải là số từ {min} đến {max} +@msg each of `{GRAPHICS}` must be an object mỗi mục của `{GRAPHICS}` phải là một đối tượng +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` các mục của `{GRAPHICS}` nhận symbol, face, x, y, scale và rotation, không nhận `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol mỗi mục của `{GRAPHICS}` cần `symbol`, tên của một ký hiệu +@msg `{GRAPHICS}` face must be front or back face của `{GRAPHICS}` phải là front hoặc back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} của `{GRAPHICS}` phải là số từ {lo} đến {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` phải là một mảng +@msg Graphics map onto planar faces: use the Extrude or Plane object type Đồ họa được đặt lên các mặt phẳng: hãy dùng loại đối tượng Kéo dài hoặc Mặt phẳng +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` nhận tối đa {MAX_GRAPHICS} đồ họa +@msg A plane has one face: map its graphics onto the front Mặt phẳng chỉ có một mặt: hãy đặt đồ họa của nó lên mặt trước +@msg The graphics are too complex: simplify the symbols' art Đồ họa quá phức tạp: hãy đơn giản hóa hình vẽ của các ký hiệu +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Hình vẽ của một đồ họa chỉ được lồng tối đa {MAX_LEVELS} cấp: hãy đơn giản hóa hình vẽ của ký hiệu +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Hình vẽ của một đồ họa chỉ được lồng ký hiệu tối đa {MAX_NESTING} cấp: hãy đơn giản hóa hình vẽ của ký hiệu +@msg A graphic's art holds too many objects: simplify the symbol's art Hình vẽ của một đồ họa chứa quá nhiều đối tượng: hãy đơn giản hóa hình vẽ của ký hiệu +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Một đồ họa vẽ tối đa {MAX_PIECES} phần tô và nét: hãy đơn giản hóa hình vẽ của ký hiệu +@msg `surface` must be one of {_1} `surface` phải là một trong {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} vẽ hình vẽ nguồn cho đối tượng này thay vì bề mặt 3D của nó +@msg {label} draws no faces for this object {label} không vẽ mặt nào cho đối tượng này +@msg the wireframe has more than {MAX_WIRE_EDGES} edges khung dây có hơn {MAX_WIRE_EDGES} cạnh @msg The 3D surface is too complex; simplify the shape Bề mặt 3D quá phức tạp; hãy đơn giản hóa hình dạng @msg the art could not be sampled không thể lấy mẫu tác phẩm @msg the art has no bounds tác phẩm không có giới hạn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d1e4de6bc6..b5b292b164 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5749,6 +5749,65 @@ lighting Light 光源 @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}无法从图像生成立体:请先使用“对象 › 图像描摹 › 创建”进行描摹。 @msg {label} requires a shape with at least two anchors {label}需要至少有两个锚点的形状 @msg The shape is too complex for a 3D effect 形状过于复杂,无法应用 3D 效果 +@msg Extrude & Bevel 突出与斜角 +@msg Revolve 回转 +@msg Inflate 膨胀 +@msg Rotate 旋转 +@msg Extrude & Bevel (Classic) 突出与斜角 (经典) +@msg Revolve (Classic) 回转 (经典) +@msg Rotate (Classic) 旋转 (经典) +@msg This effect 此效果 +@msg clipping paths 剪切路径 +@msg other objects 其他对象 +@msg {label} requires a group holding a shape with at least two anchors {label}需要一个包含至少有两个锚点的形状的编组 +@msg Revolve requires a group holding a shape with at least two anchors 回转需要一个包含至少有两个锚点的形状的编组 +@msg Revolve requires a shape with at least two anchors 回转需要至少有两个锚点的形状 +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. 编组上的{label}需要路径、复合路径、文字、编组、剪切组、混合和符号实例:请先释放或扩展以下内容:{what}。 +@msg {label} on a group takes at most {MAX_MEMBERS} objects 编组上的{label}最多接受 {MAX_MEMBERS} 个对象 +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke 编组或图层上的 3D 效果应作用于编组本身,而不是其填色或描边 +@msg a 3D effect on a group's fill or stroke draws nothing 编组填色或描边上的 3D 效果不绘制任何内容 +@msg a 3D effect on a group's fill or stroke draws nothing to export 编组填色或描边上的 3D 效果没有可导出的内容 +@msg The 3D object is too complex to export 3D 对象过于复杂,无法导出 +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) 该对象没有 3D 效果(突出与斜角、回转、膨胀或旋转) +@msg its 3D effect builds no solid 其 3D 效果未生成任何实体 +@msg bevel must be none or one of {_1} bevel 必须是 none 或 {_1} 之一 +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1}无法从此图稿生成实体:其符号实例、混合和剪切组包含的对象过多 +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label}无法从符号“{symbol}”的实例生成实体:该符号的图稿不可用 +@msg {_1}: the clipping path is too complex to cut the art with {_1}:剪切路径过于复杂,无法用来剪切图稿 +@msg {_1} takes at most {MAX_MEMBERS} objects {_1}最多接受 {MAX_MEMBERS} 个对象 +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1}无法从嵌套超过 {MAX_DEPTH} 层的图稿生成实体 +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient 回转的上色对此表面过于复杂;请减少分段数或简化渐变 +@msg Revolve edge must be left or right 回转:edge 必须是 left 或 right +@msg Revolve cap must be a boolean 回转:cap 必须是布尔值 +@msg the revolved object has no fill or stroke 回转对象没有填色或描边 +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor 必须是 #rrggbb 格式的不透明 RGB 颜色 +@msg lights must contain one to ten lights lights 必须包含一到十个光源 +@msg each light must be an object 每个光源必须是对象 +@msg unknown light setting: use color, intensity, rotation, height, softness or behind 未知的光源设置:请使用 color、intensity、rotation、height、softness 或 behind +@msg light {key} must be a number from {min} to {max} 光源:{key} 必须是 {min} 到 {max} 之间的数字 +@msg light color must use opaque #rrggbb 光源的 color 必须是不透明的 #rrggbb +@msg light behind must be true or false 光源的 behind 必须是 true 或 false +@msg shadow must be true or false shadow 必须是 true 或 false +@msg shadowPosition must be behind or below shadowPosition 必须是 behind 或 below +@msg {key} must be a number from {min} to {max} {key} 必须是 {min} 到 {max} 之间的数字 +@msg each of `{GRAPHICS}` must be an object `{GRAPHICS}` 的每个条目必须是对象 +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` `{GRAPHICS}` 的条目接受 symbol、face、x、y、scale 和 rotation,不接受 `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol `{GRAPHICS}` 的每个条目都需要 `symbol`,即符号的名称 +@msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` 的 face 必须是 front 或 back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` 的 {key} 必须是 {lo} 到 {hi} 之间的数字 +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` 必须是数组 +@msg Graphics map onto planar faces: use the Extrude or Plane object type 图形映射到平坦的面上:请使用突出或平面对象类型 +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` 最多接受 {MAX_GRAPHICS} 个图形 +@msg A plane has one face: map its graphics onto the front 平面只有一个面:请将其图形映射到正面 +@msg The graphics are too complex: simplify the symbols' art 图形过于复杂:请简化符号的图稿 +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art 图形的图稿最多可嵌套 {MAX_LEVELS} 层:请简化符号的图稿 +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art 图形的图稿最多可嵌套 {MAX_NESTING} 层符号:请简化符号的图稿 +@msg A graphic's art holds too many objects: simplify the symbol's art 图形的图稿包含过多对象:请简化符号的图稿 +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art 一个图形最多绘制 {MAX_PIECES} 个填色和描边:请简化符号的图稿 +@msg `surface` must be one of {_1} `surface` 必须是 {_1} 之一 +@msg {label} draws the source art for this object instead of its 3D surface {label}为此对象绘制源图稿,而不是其 3D 表面 +@msg {label} draws no faces for this object {label}没有为此对象绘制任何面 +@msg the wireframe has more than {MAX_WIRE_EDGES} edges 线框的边超过 {MAX_WIRE_EDGES} 条 @msg The 3D surface is too complex; simplify the shape 3D 表面过于复杂;请简化形状 Transform Objects 变换对象 @msg unknown parameter `{key}`; did you mean `{near}`? 未知参数 `{key}`;您是否想输入 `{near}`? diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8e49ed109a..b965d38ad0 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5581,6 +5581,65 @@ lighting Light 光源 @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}無法從影像建立立體:請先使用「物件 › 影像描圖 › 製作」描圖。 @msg {label} requires a shape with at least two anchors {label}需要至少有兩個錨點的形狀 @msg The shape is too complex for a 3D effect 形狀過於複雜,無法套用 3D 效果 +@msg Extrude & Bevel 突出與斜角 +@msg Revolve 迴轉 +@msg Inflate 膨脹 +@msg Rotate 旋轉 +@msg Extrude & Bevel (Classic) 突出與斜角 (經典) +@msg Revolve (Classic) 迴轉 (經典) +@msg Rotate (Classic) 旋轉 (經典) +@msg This effect 此效果 +@msg clipping paths 剪裁路徑 +@msg other objects 其他物件 +@msg {label} requires a group holding a shape with at least two anchors {label}需要一個包含至少有兩個錨點之形狀的群組 +@msg Revolve requires a group holding a shape with at least two anchors 迴轉需要一個包含至少有兩個錨點之形狀的群組 +@msg Revolve requires a shape with at least two anchors 迴轉需要至少有兩個錨點的形狀 +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. 群組上的{label}需要路徑、複合路徑、文字、群組、剪裁群組、漸變和符號實例:請先釋放或展開以下內容:{what}。 +@msg {label} on a group takes at most {MAX_MEMBERS} objects 群組上的{label}最多接受 {MAX_MEMBERS} 個物件 +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke 群組或圖層上的 3D 效果應套用在群組本身,而不是其填色或筆畫 +@msg a 3D effect on a group's fill or stroke draws nothing 群組填色或筆畫上的 3D 效果不會繪製任何內容 +@msg a 3D effect on a group's fill or stroke draws nothing to export 群組填色或筆畫上的 3D 效果沒有可匯出的內容 +@msg The 3D object is too complex to export 3D 物件過於複雜,無法匯出 +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) 此物件沒有 3D 效果 (突出與斜角、迴轉、膨脹或旋轉) +@msg its 3D effect builds no solid 其 3D 效果沒有產生任何實體 +@msg bevel must be none or one of {_1} bevel 必須是 none 或 {_1} 其中之一 +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1}無法從此圖稿產生實體:其符號實例、漸變和剪裁群組包含的物件過多 +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label}無法從符號「{symbol}」的實例產生實體:該符號的圖稿無法使用 +@msg {_1}: the clipping path is too complex to cut the art with {_1}:剪裁路徑過於複雜,無法用來剪裁圖稿 +@msg {_1} takes at most {MAX_MEMBERS} objects {_1}最多接受 {MAX_MEMBERS} 個物件 +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1}無法從巢狀超過 {MAX_DEPTH} 層的圖稿產生實體 +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient 迴轉的上色對此表面過於複雜;請減少分段數或簡化漸層 +@msg Revolve edge must be left or right 迴轉:edge 必須是 left 或 right +@msg Revolve cap must be a boolean 迴轉:cap 必須是布林值 +@msg the revolved object has no fill or stroke 迴轉物件沒有填色或筆畫 +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor 必須是 #rrggbb 格式的不透明 RGB 顏色 +@msg lights must contain one to ten lights lights 必須包含一到十個光源 +@msg each light must be an object 每個光源都必須是物件 +@msg unknown light setting: use color, intensity, rotation, height, softness or behind 未知的光源設定:請使用 color、intensity、rotation、height、softness 或 behind +@msg light {key} must be a number from {min} to {max} 光源:{key} 必須是介於 {min} 到 {max} 之間的數字 +@msg light color must use opaque #rrggbb 光源的 color 必須是不透明的 #rrggbb +@msg light behind must be true or false 光源的 behind 必須是 true 或 false +@msg shadow must be true or false shadow 必須是 true 或 false +@msg shadowPosition must be behind or below shadowPosition 必須是 behind 或 below +@msg {key} must be a number from {min} to {max} {key} 必須是介於 {min} 到 {max} 之間的數字 +@msg each of `{GRAPHICS}` must be an object `{GRAPHICS}` 的每個項目都必須是物件 +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` `{GRAPHICS}` 的項目接受 symbol、face、x、y、scale 和 rotation,不接受 `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol `{GRAPHICS}` 的每個項目都需要 `symbol`,也就是符號的名稱 +@msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` 的 face 必須是 front 或 back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` 的 {key} 必須是介於 {lo} 到 {hi} 之間的數字 +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` 必須是陣列 +@msg Graphics map onto planar faces: use the Extrude or Plane object type 圖形會對應到平坦的面:請使用「突出」或「平面」物件類型 +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` 最多接受 {MAX_GRAPHICS} 個圖形 +@msg A plane has one face: map its graphics onto the front 平面只有一個面:請將其圖形對應到正面 +@msg The graphics are too complex: simplify the symbols' art 圖形過於複雜:請簡化符號的圖稿 +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art 圖形的圖稿最多可巢狀 {MAX_LEVELS} 層:請簡化符號的圖稿 +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art 圖形的圖稿最多可巢狀 {MAX_NESTING} 層符號:請簡化符號的圖稿 +@msg A graphic's art holds too many objects: simplify the symbol's art 圖形的圖稿包含過多物件:請簡化符號的圖稿 +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art 一個圖形最多繪製 {MAX_PIECES} 個填色和筆畫:請簡化符號的圖稿 +@msg `surface` must be one of {_1} `surface` 必須是 {_1} 其中之一 +@msg {label} draws the source art for this object instead of its 3D surface {label}為此物件繪製原始圖稿,而不是其 3D 表面 +@msg {label} draws no faces for this object {label}沒有為此物件繪製任何面 +@msg the wireframe has more than {MAX_WIRE_EDGES} edges 線框的邊超過 {MAX_WIRE_EDGES} 條 @msg The 3D surface is too complex; simplify the shape 3D 表面過於複雜;請簡化形狀 Transform Objects 變形物件 @msg unknown parameter `{key}`; did you mean `{near}`? 未知的參數 `{key}`;您是指 `{near}` 嗎? diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1ce5c5fda5..e050bb4045 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -859,6 +859,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). - A tool's name in a Learn tutorial step is a blue link that shows where the tool is without choosing it (`learn.locateTool {tool}`): its toolbar button is marked, a collapsed group's flyout opens with the tool's row marked, a torn-off strip comes to the front, and a tool no shown toolbar holds is marked in the All Tools drawer. The mark, its 3 seconds and the hidden-toolbar cases are VectorCraft's own rules ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`, the `a_learn_link_*` tests in `toolbar::tests`). - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). + - The errors of the 3D effects (Extrude & Bevel, Revolve, Inflate, Rotate and their Classic forms) show in the status bar in the interface language: the art a 3D effect cannot build a solid from, invalid light, graphics and surface settings, and the notes for an object that draws no 3D faces or more wireframe edges than the limit. Each has a row in every catalog that translates all status and error messages, and the effect's name and the values in the message are translated or kept as written (`three_d_refusals_are_translated_in_every_complete_catalog`, `three_d_refusals_translate_with_their_values`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). - Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, and a command that writes one format (`document.exportPdf`, `document.exportEps`, `document.exportDxf`) or takes one format's options (`document.pdfSettings`, `pdf.preset.save`) refuses a key that format doesn't take. An unknown key inside `marks`, `bleed`, `compression`, `output`, `advanced`, `security` or `flattener` is refused by its path. - Save commands run through MCP, the control channel or the CLI (`document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`, `app.save`) refuse a key that no save format takes, beside the path or in `options`. Export for Screens refuses a key it doesn't take, and a key that no format takes in a format row or in a format's settings, by its path (`formats[1].marks.useDocumentBleed`); Export Assets (`assets.export`, `assets.settings.set`) checks the rows and settings it is given the same way. From fe489405d76e2d2c1e90b2edc7612ca8cc3ce854 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 20:22:12 -0800 Subject: [PATCH 16/17] Package: a failed save before packaging writes no package With Background Save on, Package's OK started the save of a dirty document on the worker and then wrote the package whether or not that save succeeded. With both preferences off, a failed save already stopped the package, so the result depended on the preferences. The package now starts only after the save is written, with both preferences in every combination. With Background Export on, it is still collected from the document as it was at OK and starts once the save's result arrives; when the save fails, or asks a question first, no package is written and the save's error stays in the status bar. With Background Export off, the dialog shows the save's error. background::after runs a step on the UI thread once a job's result is applied. --- crates/ui-egui/src/background.rs | 13 ++++++ crates/ui-egui/src/dialogs/package.rs | 50 ++++++++++++++++++--- crates/ui-egui/src/dialogs/tests_package.rs | 36 ++++++++++++++- docs/control-protocol.md | 4 +- 4 files changed, 92 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/background.rs b/crates/ui-egui/src/background.rs index 16b87a6576..806434fa1f 100644 --- a/crates/ui-egui/src/background.rs +++ b/crates/ui-egui/src/background.rs @@ -190,6 +190,19 @@ pub fn run_with_progress( then(app, r) } +/// Run `f` on the UI thread once job `id` has finished, right after the job's own result is +/// applied, with that applied result (an error when the work failed). Nothing runs when no job +/// `id` is in progress. +pub fn after(app: &mut VectorcraftApp, id: u64, f: impl FnOnce(&mut VectorcraftApp, &Result) + 'static) { + let Some(job) = app.background.jobs.iter_mut().find(|j| j.id == id) else { return }; + let then = std::mem::replace(&mut job.then, Box::new(|_, r| r)); + job.then = Box::new(move |app, r| { + let r = then(app, r); + f(app, &r); + r + }); +} + /// Run `f` with every save and export it starts written at once, as with the preferences off, so /// its result is the written file or the error (a control client's `wait: true`). The jobs already /// running finish first, so none of them writes over its files afterwards. diff --git a/crates/ui-egui/src/dialogs/package.rs b/crates/ui-egui/src/dialogs/package.rs index 40a8db9cff..2ff2097975 100644 --- a/crates/ui-egui/src/dialogs/package.rs +++ b/crates/ui-egui/src/dialogs/package.rs @@ -1,14 +1,14 @@ //! File → Package…: where the package folder goes, its name, and what it collects (the linked //! files, in a folder of their own or not, relinked or not; the fonts; the report). A document -//! never saved is saved first (Save As); unsaved changes are saved before packaging. The web -//! downloads the package as a zip. +//! never saved is saved first (Save As); unsaved changes are saved before packaging, and when that +//! save fails no package is written. The web downloads the package as a zip. //! //! Fields: `folder` (the location), `name` (the package folder), `copyLinks`, `linksFolder`, //! `relink`, `copyFonts`, `report`. OK runs `file.package` with them, then offers to show the //! package folder (`file.showPackage`). On the desktop with Background Export on, the package is //! collected and written on the background thread ([`crate::background`]) after the dialog closes. -use std::cell::Cell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use serde_json::{Value, json}; @@ -108,21 +108,35 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } else if d.str("folder").trim().is_empty() { return Err("choose a location for the package".into()); } - // The package holds the document as saved. + // The package holds the document as saved: when the save fails, or asks a question and writes + // nothing yet, no package is written. + let mut saving = None; if app.session.active().is_some_and(|st| st.is_dirty()) { let r = io::save(app, vectorcraft_engine::cmd::fileio::SaveMode::Save, &json!({}), false)?; // The save asks first (it would replace a file that loses what opening it left out). if r.get("pending").is_some() { return Ok(r); } + // Background Save: the save is written on the worker, and its result decides the package. + if r["background"] == true { + saving = app.background.jobs.last().map(background::Job::id); + } } // Background Export (Preferences › File Handling): the linked files and fonts are collected // and the package is written from the document as it is now, while editing goes on. The jobs - // started before it (a save started above, an earlier package to the same folder) are written - // first: the worker runs its jobs in order, and a package made at once waits for them. + // started before it (an earlier package to the same folder) are written first: the worker + // runs its jobs in order, and a package made at once waits for them. let enabled = app.session.prefs.background_export; if !enabled { + let outcome: Rc>>> = Rc::default(); + if let Some(id) = saving { + let applied = outcome.clone(); + background::after(app, id, move |_, r| *applied.borrow_mut() = Some(r.clone())); + } background::wait_all(app); + if let Some(r) = outcome.take().filter(|r| !saved(r)) { + return r; + } } let package = app.session.prepare_package(¶ms).map_err(|e| e.to_string()).inspect_err(|e| app.ui.status = e.clone())?; let label = format!("Packaging {}", d.str("name")); @@ -134,7 +148,23 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { |path: &str, bytes: &[u8]| write(path, bytes).map_err(|e| EngineError::Other(if e.contains(path) { e } else { format!("{path}: {e}") })); package.write(&mut write).map_err(|e| e.to_string()) }; - let r = background::run(app, enabled, label, None, work, move |app, r| packaged(app, r, web, in_background.get()))?; + let start = + move |app: &mut VectorcraftApp| background::run(app, enabled, label, None, work, move |app, r| packaged(app, r, web, in_background.get())); + // Both in the background: the package, collected from the document as it is now, starts once + // the save is written. + if let Some(id) = saving.filter(|_| enabled) { + later.set(true); + app.ui.dialog = None; + background::after(app, id, move |app, r| { + if saved(r) + && let Err(e) = start(app) + { + app.status(e); + } + }); + return Ok(json!({ "background": true })); + } + let r = start(app)?; if r["background"] == true { later.set(true); app.ui.dialog = None; @@ -142,6 +172,12 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { Ok(r) } +/// Did a save whose applied result is `r` write the document (no error, and no question asked +/// before writing)? +fn saved(r: &Result) -> bool { + r.as_ref().is_ok_and(|r| r.get("pending").is_none()) +} + /// Report the package `r` (`file.package`'s reply): the status bar, the web's download, and the /// offer to show the package folder. `later`: it was written in the background, after the dialog /// closed, and a dialog open now is another one, which stays (the status bar names the folder). diff --git a/crates/ui-egui/src/dialogs/tests_package.rs b/crates/ui-egui/src/dialogs/tests_package.rs index 41253029a2..5c9e899354 100644 --- a/crates/ui-egui/src/dialogs/tests_package.rs +++ b/crates/ui-egui/src/dialogs/tests_package.rs @@ -181,7 +181,7 @@ fn a_dirty_documents_save_is_written_before_its_package() { let (mut app, files) = saved_with_worker(&dir, &gate); let (doc, out) = (dir.path("card.vectorcraft"), dir.path("out")); // With Background Export on, the save and the package both run in the background, and the - // worker writes the save first. + // package starts once the save is written. app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); package_dialog(&mut app, &out); // A thread opens the gate after 10 s at the latest, so a confirm that waits for the save ends. @@ -191,7 +191,7 @@ fn a_dirty_documents_save_is_written_before_its_package() { opener.open(); }); assert_eq!(confirm(&mut app).unwrap()["background"], true); - assert_eq!(app.background.jobs.len(), 2, "the save and the package"); + assert_eq!(app.background.jobs.len(), 1, "the save; the package waits for it"); gate.open(); background::wait_all(&mut app); let paths = written(&files); @@ -216,6 +216,38 @@ fn a_dirty_documents_save_is_written_before_its_package() { assert!(app.ui.status.starts_with("Packaged 3 files in "), "the package's status came last: {}", app.ui.status); } +/// A save before packaging that fails writes no package, with Background Export on or off. +#[test] +fn a_failed_save_before_packaging_writes_no_package() { + let dir = Folder::new("failed-save"); + let (doc, out) = (dir.path("card.vectorcraft"), dir.path("out")); + let root = std::path::Path::new(&out).join("card Folder"); + for background_export in [true, false] { + let mut app = saved(&dir); + let (files, refused) = (Files::default(), doc.clone()); + let recorded = files.clone(); + app.services.write_shared = Some(Arc::new(move |p: &str, b: &[u8]| { + if p == refused { + return Err("Access is denied. (os error 5)".into()); + } + recorded.lock().unwrap().push((p.to_string(), b.to_vec())); + Ok(()) + })); + app.session.execute("prefs.set", &json!({"values": {"backgroundExport": background_export}})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + package_dialog(&mut app, &out); + let r = confirm(&mut app); + background::wait_all(&mut app); + assert!(written(&files).is_empty(), "Background Export {background_export}: {:?}", written(&files)); + assert!(!root.exists(), "Background Export {background_export}: no package folder"); + assert!(app.session.active().unwrap().is_dirty(), "the save failed"); + match r { + Ok(r) => assert!(background_export && r["background"] == true, "{r}"), + Err(e) => assert!(!background_export && e.contains("Access is denied"), "{e}"), + } + } +} + #[test] fn a_package_made_at_once_waits_for_one_in_the_background() { let dir = Folder::new("twice"); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 19f245175d..802ffb4a94 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -762,8 +762,8 @@ the `PATH` elsewhere), and any other file, or a raster image without PhotoCraft Package: File → Package… (`ui.packageDialog`) opens the `package` dialog for a saved document (a document never saved gets a `confirm` dialog whose OK runs `file.saveAs`). Fields: `folder` (the location), `name` (the package folder), -`copyLinks`, `linksFolder`, `relink`, `copyFonts`, `report`. `ui.dialog.confirm` saves unsaved changes, runs -`file.package` and then opens a `confirm` dialog whose OK runs `file.showPackage {folder}`; the web downloads the zip +`copyLinks`, `linksFolder`, `relink`, `copyFonts`, `report`. `ui.dialog.confirm` saves unsaved changes (when that save +fails, no package is written, also with Background Save and Background Export on), runs `file.package` and then opens a `confirm` dialog whose OK runs `file.showPackage {folder}`; the web downloads the zip instead. Document Info's flyout picks a category and Save… runs `docInfo.save {path?, selectionOnly?, category?}` with the category the panel shows (none for All Categories, which writes every category). From 9142716ddccba5c4ebc216dd4a98ffac9bd71201 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:28:53 -0500 Subject: [PATCH 17/17] Roadmap M3 row: the library-panel note goes in the Status cell (#2992) --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index bc4e159c2d..cf22043f6f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -149,7 +149,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M0 | Skeleton + vertical slice | ✅ done | — | | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | -| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | Library panels' lists (swatch, Color Book, brush, symbol and graphic style libraries) fill a taller panel above its footer (#2992; scores and estimates unchanged); Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); 4–8 | +| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; Library panels' lists (swatch, Color Book, brush, symbol and graphic style libraries) fill a taller panel above its footer (#2992; scores and estimates unchanged); | Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); 4–8 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 |