From 1d41b403e1d1d1b2e48801d75f749e9f7970c960 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:07:41 -0800 Subject: [PATCH 01/68] 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/68] 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 7215594abc3e4f3ba8fbc796c95873169f3a5bec Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:33:41 -0500 Subject: [PATCH 03/68] Graphic Styles: the list fills a taller panel above its footer (#2992) --- crates/ui-egui/src/panels/graphic_styles.rs | 43 ++++++++++++++++++++- docs/ui-parity.md | 4 +- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index 0c702636ec..fe38851b44 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -465,9 +465,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let sel = selected(ui.ctx(), &doc); let view: View = pstate(ui.ctx(), "gs-view"); let mut ev = Events::default(); + // In a sized panel the list fills the space above the footer (#2992), as the Artboards list + // does; elsewhere it keeps its 220 pt. + let fixed = widgets::panel_footer_bottom(ui).is_some(); + let list_height = widgets::panel_list_height(ui, 220.0, 2.0 + ui.spacing().item_spacing.y); widgets::list_box(ui, |ui| { - let out = egui::ScrollArea::vertical().id_salt("gs-scroll").max_height(220.0).show(ui, |ui| { - ui.set_min_height(120.0); + let scroll = egui::ScrollArea::vertical() + .id_salt("gs-scroll") + .max_height(list_height) + .min_scrolled_height(list_height.min(64.0)) + .auto_shrink([false, !fixed]); + let out = scroll.show(ui, |ui| { + ui.set_min_height(if fixed { list_height.min(120.0) } else { 120.0 }); ui.set_width(ui.available_width()); if doc.graphic_styles.is_empty() { super::empty_state(ui, "dc-graphic-styles", tl!("No graphic styles"), tl!("Select styled art and click New Graphic Style.")); @@ -847,6 +856,36 @@ mod tests { app.session.active().unwrap().doc.graphic_styles.iter().map(|g| g.name.clone()).collect() } + /// In a panel made taller the list fills the space above its footer instead of staying 220 pt + /// tall with blank space below it (#2992); outside a sized panel it keeps that height. + #[test] + fn the_list_fills_a_tall_panel_above_its_footer() { + let mut app = app(); + for i in 0..30 { + app.session.execute("graphicStyle.new", &json!({"name": format!("Style {i:02}")})).unwrap(); + } + let ctx = egui::Context::default(); + set_pstate(&ctx, "gs-view", View::LargeList); + // The clip rect of the first style's name: the list's visible area. + let mut list = |bottom: Option| { + let mut clip = None; + for _ in 0..3 { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(260.0, 700.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| show(&mut app, ui)); + }); + out.textures_delta.clear(); + clip = out.shapes.iter().find(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == "Style 00")).map(|c| c.clip_rect); + } + clip.expect("the first style's row is drawn") + }; + let tall = list(Some(650.0)); + assert!(tall.height() > 400.0 && tall.bottom() <= 650.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:?}"); + } + #[test] fn list_view_names_the_styles_and_deletes_the_selected_ones_after_asking() { let mut app = app(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e5f9b1cf19..a208a43de6 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 (Graphic Styles list fills 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 | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 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` | +| Graphic Styles list fills a taller panel (#2992) | done | Docked, floating or in a resized flyout, the list takes the height above the panel's footer, as the Artboards list does; outside a sized panel it stays 220 pt; `the_list_fills_a_tall_panel_above_its_footer` | | 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 2b975d620ebb882f28b1142d4f13a249e685001c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 16:02:00 -0800 Subject: [PATCH 04/68] 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 d5f0fe3a3fa7f36ae682e833f04c46a0aeb62bb5 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:15:40 -0500 Subject: [PATCH 05/68] PDF import: glyphs in a span with an empty ActualText come in as outlines (#6971) --- crates/pdf/src/import/actual_text.rs | 14 +++++++++++++- crates/pdf/src/import/builder.rs | 5 +++-- crates/pdf/src/tests/actual_text.rs | 25 +++++++++++++++++++++++++ docs/file-format-parity.md | 5 +++-- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/pdf/src/import/actual_text.rs b/crates/pdf/src/import/actual_text.rs index d6d18a7566..6d7ddbaf9d 100644 --- a/crates/pdf/src/import/actual_text.rs +++ b/crates/pdf/src/import/actual_text.rs @@ -11,6 +11,9 @@ pub(super) struct ActualText { depth: usize, owner: Option, pending: Option, + /// The owner's replacement is empty: its content has no text, so its glyphs stay art + /// ([`Self::textless`], #6971). + empty: bool, } impl ActualText { @@ -22,6 +25,7 @@ impl ActualText { if self.owner == Some(self.depth) { self.owner = None; self.pending = None; + self.empty = false; } self.depth = self.depth.saturating_sub(1); } @@ -30,6 +34,12 @@ impl ActualText { self.owner.is_some() } + /// Inside a span whose replacement text is empty: the glyphs there are no text, but they + /// still draw, so they come in as outlines rather than as type with no characters (#6971). + pub(super) fn textless(&self) -> bool { + self.owner.is_some() && self.empty + } + pub(super) fn set(&mut self, bytes: &[u8]) -> bool { if self.active() { return true; @@ -39,8 +49,10 @@ impl ActualText { if self.depth == 0 || self.depth > 128 || bytes.len() > 64 * 1024 { return false; } + let text = crate::import_scan::text_string(bytes); self.owner = Some(self.depth); - self.pending = Some(crate::import_scan::text_string(bytes)); + self.empty = text.is_empty(); + self.pending = Some(text); true } } diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 6d47168716..21d862d2fd 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -800,7 +800,8 @@ impl<'p> Builder<'p> { } /// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps - /// its outline (no Unicode text, mirrored, or not type mode). No `paint`: invisible text, + /// its outline (no Unicode text, mirrored, not type mode, or in a span whose ActualText is + /// empty). No `paint`: invisible text, /// type with neither fill nor stroke. pub(super) fn type_glyph( &mut self, @@ -811,7 +812,7 @@ impl<'p> Builder<'p> { stroke: Option<&StrokeProps>, overprint: bool, ) -> bool { - if self.text_as != TextAs::Text || self.nested > 0 { + if self.text_as != TextAs::Text || self.nested > 0 || self.actual.textless() { return false; } let authored = self.actual.active(); diff --git a/crates/pdf/src/tests/actual_text.rs b/crates/pdf/src/tests/actual_text.rs index 8e6bd4810b..7ff4599a57 100644 --- a/crates/pdf/src/tests/actual_text.rs +++ b/crates/pdf/src/tests/actual_text.rs @@ -121,6 +121,31 @@ fn actual_text_empty_and_nested_owners() { } } +/// Glyphs in a span whose ActualText is empty have no text, but they still draw: they stay on the +/// page as outlines in their paint, filled and stroked as the text rendering mode says (#6971). +#[test] +fn empty_actual_text_keeps_the_glyphs_art() { + let outlines = |report: &ImportReport| { + let mut found = vec![]; + report.document.walk(|node| { + if matches!(node.kind, NodeKind::Path { .. }) { + found.push((node.appearance.fill().is_some(), node.appearance.stroke().is_some(), node.geometric_bounds())); + } + }); + found + }; + for (mode, fill, stroke) in [("0", true, false), ("1", false, true), ("2", true, true)] { + let content = format!("/Span << /ActualText () >> BDC 1 0 0 rg 0 0 1 RG 3 w {mode} Tr (Halo) Tj 0 Tr EMC 0 0 0 rg (C) Tj"); + let report = open(&content); + assert_eq!(plain(&report), "C", "mode {mode}: the span adds no text"); + let found = outlines(&report); + assert!(found.iter().any(|(f, _, _)| *f == fill) && found.iter().any(|(_, s, _)| *s == stroke), "mode {mode}: the glyphs' art: {found:?}"); + let ink = found.iter().filter_map(|(_, _, b)| *b).fold(None, |acc: Option, b| Some(acc.map_or(b, |a| a.union(b)))); + let ink = ink.unwrap_or_else(|| panic!("mode {mode}: no outlines")); + assert!(ink.x0 >= 15.0 && ink.x1 > 40.0 && ink.y0 > 50.0 && ink.y1 < 140.0, "mode {mode}: where the word is set: {ink:?}"); + } +} + #[test] fn actual_text_decodes_pdf_strings_and_preserves_authored_order() { for (encoded, expected) in [ diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 20cce4636a..05a709042c 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-13 · **Change:** minor (PDF import: glyphs in a span with an empty ActualText come in as outlines instead of being left out, #6971; previously 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) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). @@ -308,7 +308,7 @@ Stop opacity on translated artboards remains outside this regression coverage. ## PDF editable imports -- [x] Explicit `/ActualText` replaces covered glyph text once, including empty strings and nested spans (outer owner wins), while later glyphs retain pen geometry. Synthetic regressions in `crates/pdf/src/tests/actual_text.rs`; no added fonts/assets. UTF-16BE, BOM UTF-8 and PDFDocEncoding reuse the reader's text-string decoder. +- [x] Explicit `/ActualText` replaces covered glyph text once, including empty strings and nested spans (outer owner wins), while later glyphs retain pen geometry. Glyphs inside a span whose ActualText is empty add no text but still draw: they come in as outlines in their fill and stroke (#6971, `empty_actual_text_keeps_the_glyphs_art`). Synthetic regressions in `crates/pdf/src/tests/actual_text.rs`; no added fonts/assets. UTF-16BE, BOM UTF-8 and PDFDocEncoding reuse the reader's text-string decoder. - [x] Authored ordering is protected per stretch; ordinary stretches still reconstruct visual-order text. Outlines mode is unchanged. - [ ] Exact redistribution of one replacement over several lines/styles, and general mixed-direction paragraph layout. Replacement uses the first placed glyph's line/style. Tags deeper than 128 and replacements over 64 KiB encoded (at most 192 KiB decoded) fall back to glyph mappings with a warning; an outer accepted owner survives deeper tags. @@ -322,6 +322,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | PDF import: glyphs inside a marked-content span with an empty `ActualText` were left out of the page; they now come in as outlines in their fill and stroke, adding no text (#6971); scores and estimates 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 bb50d438c85fd3e32a7771cca1e543118701c55e Mon Sep 17 00:00:00 2001 From: jmverges Date: Thu, 12 Nov 2026 21:23:26 +0000 Subject: [PATCH 06/68] .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 07/68] 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 25c1a56755df8efc871484578ab49f384bf6f7d5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 17:42:59 -0800 Subject: [PATCH 08/68] Patterns: canceling a new pattern leaves no swatch or paint of it Cancel on a pattern that Make created for the edit now removes every swatch of it, a color group's included, and turns every paint of it left in the document into no paint: art outside the editing layer, opacity mask art, type runs, symbol definitions, graph designs, other patterns' tiles (a copy Save a Copy made included), graphic and character styles, brush art, and the fill, stroke and appearance new art takes. Delete Pattern clears its paints in the same places, and a Pattern Options rename renames them there; both reached only the layers before. Delete's `unpainted` counts the document paints it cleared. Cancel after a Pattern Options rename during the edit gives the pattern its old name back with its swatches and paints, unless another pattern, swatch or color group has taken that name; before, the definition went back to the old name while its swatches and paints kept the new one. Undo and Redo restore new art's paints with the document, including the appearance inherited from the selection with New Art Has Basic Appearance off. A rename preview that is replaced or canceled starts again from the paints the interaction began with, and a canceled preview takes the selection's appearance again. --- crates/doc/src/swatches.rs | 137 ++++++++---- crates/engine/src/cmd/colormgmt.rs | 8 +- crates/engine/src/cmd/patterncmds.rs | 155 ++++++++------ crates/engine/src/cmd/swatch.rs | 70 +++--- crates/engine/src/history.rs | 3 +- crates/engine/src/lib.rs | 7 +- crates/engine/src/session/edit.rs | 10 +- crates/engine/src/tests/pattern.rs | 308 ++++++++++++++++++++++++++- docs/mcp.md | 11 + docs/target-app-parity.md | 1 + 10 files changed, 574 insertions(+), 136 deletions(-) diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 80db3d923a..e245e66156 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -5,7 +5,7 @@ //! tint (`Paint::Solid.swatch` / `.tint`, `GradientStop.swatch` / `.tint`); a tint swatch is a //! swatch whose own paint links to its base that way. [`Document::map_solid_paints`] rewrites //! those links (and colours) wherever art and swatches hold them, copying only the nodes that -//! change. +//! change; [`Document::map_paints`] replaces whole paints of any kind in the same places. use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -124,6 +124,52 @@ impl Document { /// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are /// copied. Returns the number of changed paints. pub fn map_solid_paints(&mut self, f: &mut LinkFn) -> usize { + let mut n = self.map_art_paints(&mut relink(f)); + for gs in &mut self.graphic_styles { + for it in &mut gs.appearance.items { + n += usize::from(item_paint_mut(it).map_links(f)); + } + } + for w in self.swatches_iter_mut() { + n += usize::from(w.paint.map_links(f)); + } + let mut g = relink(f); + for def in &mut self.char_styles { + for key in ["fill", "stroke"] { + n += usize::from(map_style_paint(&mut def.attrs, key, &mut g)); + } + } + n + } + + /// Visit every paint in the places [`Document::map_solid_paints`] visits (fills, strokes, text + /// runs, image colorize paints and dynamic symbol instances' own paints in the art, opacity masks + /// included, symbol definitions, graph designs, pattern tiles and the tile that pattern + /// editing's Cancel puts back; graphic styles; the swatches; character styles' fill and + /// stroke), whatever its kind. `f(paint)` gives the paint to put in its place, or `None` to keep + /// it. Only nodes holding a changed paint (and the paths to them) are copied. Returns the number + /// of changed paints. + pub fn map_paints(&mut self, f: &mut PaintFn) -> usize { + let mut n = self.map_art_paints(f); + for gs in &mut self.graphic_styles { + for it in &mut gs.appearance.items { + n += usize::from(replace_paint(item_paint_mut(it), f)); + } + } + for w in self.swatches_iter_mut() { + n += usize::from(replace_paint(&mut w.paint, f)); + } + for def in &mut self.char_styles { + for key in ["fill", "stroke"] { + n += usize::from(map_style_paint(&mut def.attrs, key, f)); + } + } + n + } + + /// [`Document::map_paints`] limited to the node trees: the art, symbol definitions, graph + /// designs, pattern tiles and the tile that pattern editing's Cancel puts back. + fn map_art_paints(&mut self, f: &mut PaintFn) -> usize { let mut n = map_trees(&mut self.layers, f); for s in &mut self.symbols { n += map_tree(&mut s.art, f); @@ -134,19 +180,6 @@ impl Document { for p in self.patterns.iter_mut().chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())) { n += map_trees(&mut p.art, f); } - for gs in &mut self.graphic_styles { - for it in &mut gs.appearance.items { - n += usize::from(item_paint_mut(it).map_links(f)); - } - } - for w in self.swatches_iter_mut() { - n += usize::from(w.paint.map_links(f)); - } - for def in &mut self.char_styles { - for key in ["fill", "stroke"] { - n += usize::from(map_style_paint(&mut def.attrs, key, f)); - } - } n } @@ -156,9 +189,10 @@ impl Document { let roots: Vec = ids.iter().copied().filter(|id| !self.ancestry(*id).is_some_and(|a| a[..a.len() - 1].iter().any(|p| ids.contains(p)))).collect(); let mut n = 0; + let mut g = relink(f); for id in roots { let Some(node) = self.node(id) else { continue }; - if let Some((new, count)) = map_node(node, f) + if let Some((new, count)) = map_node(node, &mut g) && let Some(slot) = self.node_mut(id) { *slot = new; @@ -258,14 +292,39 @@ impl FreeSwatchNames { /// true when it changed them. pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option, &mut f32) -> bool + 'a; -/// Apply `f` to the paint a character style's attribute `key` holds, as [`Paint::map_links`] -/// does, and store the result in the attribute. True when it changed. -fn map_style_paint(attrs: &mut serde_json::Map, key: &str, f: &mut LinkFn) -> bool { - let Some(value) = attrs.get_mut(key) else { return false }; - let Ok(mut paint) = serde_json::from_value::(value.clone()) else { return false }; - if !paint.map_links(f) { - return false; +/// What [`Document::map_paints`] applies to each paint: the paint to put in its place, or `None` +/// to keep it. +pub type PaintFn<'a> = dyn FnMut(&Paint) -> Option + 'a; + +/// `f` as a [`PaintFn`]: a solid or gradient paint with `f` applied ([`Paint::map_links`]) when +/// `f` changed it, `None` for every other paint. +fn relink<'a>(f: &'a mut LinkFn<'_>) -> impl FnMut(&Paint) -> Option + 'a { + move |p| match p { + Paint::Solid { .. } | Paint::Gradient(_) => { + let mut q = p.clone(); + q.map_links(f).then_some(q) + } + _ => None, } +} + +/// Put `f(p)` in place of `p` when it gives a paint. True when it did. +fn replace_paint(p: &mut Paint, f: &mut PaintFn) -> bool { + match f(p) { + Some(q) => { + *p = q; + true + } + None => false, + } +} + +/// Apply `f` to the paint a character style's attribute `key` holds and store the result in the +/// attribute. An attribute that is not a paint stays as it is. True when it changed. +fn map_style_paint(attrs: &mut serde_json::Map, key: &str, f: &mut PaintFn) -> bool { + let Some(value) = attrs.get_mut(key) else { return false }; + let Ok(paint) = serde_json::from_value::(value.clone()) else { return false }; + let Some(paint) = f(&paint) else { return false }; match serde_json::to_value(&paint) { Ok(changed) => { *value = changed; @@ -333,20 +392,11 @@ pub fn node_paints_mut(n: &mut Node) -> impl Iterator { n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke])).chain(colorize) } -/// `n` with `f` applied to its subtree's linkable colours, or `None` when nothing changed (then -/// nothing was copied). Also returns the number of changed paints. -fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> { - // Try each own solid or gradient paint on a scratch copy first, so unchanged nodes are never - // cloned. - let mut changes: Vec<(usize, Paint)> = vec![]; - for (i, p) in node_paints(n).enumerate() { - if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) { - let mut q = p.clone(); - if q.map_links(f) { - changes.push((i, q)); - } - } - } +/// `n` with `f` applied to its subtree's paints, or `None` when nothing changed (then nothing was +/// copied). Also returns the number of changed paints. +fn map_node(n: &Node, f: &mut PaintFn) -> Option<(Node, usize)> { + // `f` gives each own paint's replacement first, so unchanged nodes are never cloned. + let changes: Vec<(usize, Paint)> = node_paints(n).enumerate().filter_map(|(i, p)| f(p).map(|q| (i, q))).collect(); let mut count = changes.len(); let mut out: Option = None; if !changes.is_empty() { @@ -366,9 +416,7 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> { let mut overrides = n.extras().symbol_overrides.clone(); let mut k = 0; for p in overrides.iter_mut().flat_map(|o| [&mut o.fill, &mut o.stroke]).flatten() { - if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) && p.map_links(f) { - k += 1; - } + k += usize::from(replace_paint(p, f)); } if k > 0 { out.get_or_insert_with(|| n.clone()).extras_mut().symbol_overrides = overrides; @@ -415,6 +463,13 @@ pub fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) { /// brush: `f` applied to the linkable colors of `n` and its subtree (opacity masks included). Only /// the nodes holding a changed paint are copied. Returns the number of changed paints. pub fn map_node_solid_paints(n: &mut Node, f: &mut LinkFn) -> usize { + map_node_paints(n, &mut relink(f)) +} + +/// [`Document::map_paints`] for art a document keeps outside its trees, such as the art of a brush: +/// `f` applied to every paint of `n` and its subtree (opacity masks included). Only the nodes +/// holding a changed paint are copied. Returns the number of changed paints. +pub fn map_node_paints(n: &mut Node, f: &mut PaintFn) -> usize { match map_node(n, f) { Some((new, count)) => { *n = new; @@ -424,7 +479,7 @@ pub fn map_node_solid_paints(n: &mut Node, f: &mut LinkFn) -> usize { } } -fn map_tree(n: &mut Arc, f: &mut LinkFn) -> usize { +fn map_tree(n: &mut Arc, f: &mut PaintFn) -> usize { match map_node(n, f) { Some((new, count)) => { *n = Arc::new(new); @@ -434,7 +489,7 @@ fn map_tree(n: &mut Arc, f: &mut LinkFn) -> usize { } } -fn map_trees(v: &mut [Arc], f: &mut LinkFn) -> usize { +fn map_trees(v: &mut [Arc], f: &mut PaintFn) -> usize { v.iter_mut().map(|n| map_tree(n, f)).sum() } diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 18d4e26704..16990fc2ea 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -397,7 +397,9 @@ pub(crate) fn commit_new_art_colors(s: &mut Session) -> Result<()> { Ok(()) } -fn record_new_art_colors(s: &mut Session, before: vectorcraft_tools::PaintDefaults) -> Result<()> { +/// Record new-art paints `before` and the session's current ones with the step just made (or the +/// interaction or undo group in progress), so Undo puts `before` back and Redo the current ones. +pub(super) fn record_new_art_colors(s: &mut Session, before: vectorcraft_tools::PaintDefaults) -> Result<()> { let after = s.paint.clone(); if before != after || s.batch_stash.is_some() || s.doc()?.undo_group.is_some() { let st = s.doc_mut()?; @@ -422,7 +424,9 @@ fn record_new_art_colors(s: &mut Session, before: vectorcraft_tools::PaintDefaul Ok(()) } -fn new_art_colors_before(s: &Session) -> Result { +/// The new-art paints an undo step that starts now puts back: those at the start of the undo group +/// or interaction in progress, else the session's current ones. +pub(super) fn new_art_colors_before(s: &Session) -> Result { let st = s.doc()?; Ok(st.undo_group.as_ref().map(|g| &g.paint).or_else(|| st.interaction.as_ref().map(|it| &it.paint)).cloned().unwrap_or_else(|| s.paint.clone())) } diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 4075676ca6..dbf726f528 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -14,6 +14,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::Paint; use vectorcraft_doc::pattern::{self, Overlap, PATTERN_EDIT_LAYER, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType, pattern_paint}; +use vectorcraft_doc::swatches::PaintFn; use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection}; use vectorcraft_geom::{Point, Rect}; @@ -54,7 +55,15 @@ pub fn specs() -> Vec { in_pattern_edit, pattern_done ), - cmd!("object.pattern.cancel", "Cancel", [], None, "{} leave pattern editing mode, discarding changes", in_pattern_edit, pattern_cancel), + cmd!( + "object.pattern.cancel", + "Cancel", + [], + None, + "{} leave pattern editing mode, discarding changes; a pattern made for this edit is removed with its swatches, and every paint of it, new art's fill and stroke included, becomes none", + in_pattern_edit, + pattern_cancel + ), cmd!( "object.pattern.saveCopy", "Save a Copy", @@ -78,7 +87,7 @@ pub fn specs() -> Vec { "Delete Pattern", [], None, - "{name} delete a pattern swatch (objects painted with it lose that paint)", + "{name} delete a pattern and its swatches; every paint of it, new art's fill and stroke included, becomes none → {unpainted: the number of document paints cleared}", has_doc, pattern_delete ), @@ -403,26 +412,96 @@ pub(crate) fn finish_edit(d: &mut Document, sel: &mut Selection) -> Result Result { - s.edit("Cancel Pattern Editing", |d, sel| { + let changed = s.edit("Cancel Pattern Editing", |d, sel| { let (pe, _) = leave_edit(d, sel)?; match pe.original { - Some(orig) => { + Some(mut orig) => { + if orig.name != pe.pattern && (d.pattern(&orig.name).is_some() || swatch_name_clash(d, &pe.pattern, &orig.name)) { + orig.name = pe.pattern.clone(); + } + let back = (orig.name != pe.pattern).then(|| orig.name.clone()); if let Some(def) = d.pattern_mut(&pe.pattern) { *def = orig; } + if let Some(old) = &back { + rename_pattern_uses(d, &pe.pattern, old); + } + Ok(back.map(|old| (pe.pattern, Some(old)))) } None => { - d.patterns.retain(|p| p.name != pe.pattern); - d.swatches.retain(|sw| !matches!(&sw.paint, Paint::Pattern { pattern, .. } if *pattern == pe.pattern)); + forget_pattern(d, &pe.pattern); + Ok(Some((pe.pattern, None))) } } - Ok(()) })?; after_edit_mode(s, None)?; + if let Some((name, to)) = changed { + map_new_art_paints(s, &mut pattern_paints(&name, to.as_deref()))?; + } ok() } +/// A [`PaintFn`] that puts a paint of pattern `to` in place of each paint of pattern `name`, with +/// its placement kept, or no paint for `to` `None`. +fn pattern_paints<'a>(name: &'a str, to: Option<&'a str>) -> impl FnMut(&Paint) -> Option + 'a { + move |p| match p { + Paint::Pattern { pattern, xf } if pattern == name => Some(match to { + Some(n) => Paint::Pattern { pattern: n.to_string(), xf: *xf }, + None => Paint::None, + }), + _ => None, + } +} + +/// Remove pattern `name` from `d`: its definition and every swatch, grouped or not, whose paint is +/// the pattern. Every paint of the pattern left anywhere in the document (the art, opacity masks, +/// type, symbol definitions, graph designs, other patterns' tiles, graphic and character styles and +/// the art of brushes) becomes no paint. Returns the number of such paints. +fn forget_pattern(d: &mut Document, name: &str) -> usize { + d.patterns.retain(|p| p.name != name); + let is_it = |sw: &vectorcraft_color::Swatch| matches!(&sw.paint, Paint::Pattern { pattern, .. } if pattern == name); + d.swatches.retain(|sw| !is_it(sw)); + for g in &mut d.swatch_groups { + g.swatches.retain(|sw| !is_it(sw)); + } + super::swatch::map_all_paints(d, &mut pattern_paints(name, None)) +} + +/// Apply `f` to the fill and stroke new art takes and to the paints of its template appearance. A +/// change is recorded with the step just made, so Undo and Redo put these paints back with the +/// document. +fn map_new_art_paints(s: &mut Session, f: &mut PaintFn) -> Result<()> { + let start = super::colormgmt::new_art_colors_before(s)?; + let old = s.paint.clone(); + let pd = &mut s.paint; + let template = pd.appearance.iter_mut().flat_map(|a| a.items.iter_mut()).map(|it| match it { + vectorcraft_doc::AppearanceItem::Fill(f) => &mut f.paint, + vectorcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint, + }); + for p in [&mut pd.fill, &mut pd.stroke].into_iter().chain(template) { + if let Some(q) = f(p) { + *p = q; + } + } + if s.paint != old { + super::colormgmt::record_new_art_colors(s, start)?; + // The step takes the paints the command finally leaves, after the selection's appearance + // is inherited. + let st = s.doc()?; + if st.interaction.is_none() { + let uid = st.uid; + s.batch_paint_docs.push(uid); + } + } + Ok(()) +} + fn pattern_save_copy(s: &mut Session, p: &Value) -> Result { let wanted = text_param(p, "name", "object.pattern.saveCopy")?.map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); let name = s.edit("Save a Copy", |d, _| { @@ -524,6 +603,9 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; + if let Some(nn) = &new_name { + map_new_art_paints(s, &mut pattern_paints(&name, Some(nn)))?; + } Ok(json!({ "name": out_name })) } @@ -552,47 +634,19 @@ fn swatch_name_clash(d: &Document, old: &str, new: &str) -> bool { } /// Rename a pattern in swatches, paints and the edit record. Of the swatches named `old`, only the -/// pattern's own take the new name. +/// pattern's own take the new name. The paints are renamed wherever [`forget_pattern`] clears them. fn rename_pattern_uses(d: &mut Document, old: &str, new: &str) { - let fix = |p: &mut Paint| { - if let Paint::Pattern { pattern, .. } = p - && pattern == old - { - *pattern = new.to_string(); - } - }; - for sw in d.swatches.iter_mut().chain(d.swatch_groups.iter_mut().flat_map(|g| g.swatches.iter_mut())) { + for sw in d.swatches_iter_mut() { if sw.name == old && pattern_name(&sw.paint).is_some_and(|p| p == old) { sw.name = new.to_string(); } - fix(&mut sw.paint); } if let Some(pe) = &mut d.pattern_edit && pe.pattern == old { pe.pattern = new.to_string(); } - let ids = nodes_using(d, Some(old)); - for id in ids { - if let Some(n) = d.node_mut(id) { - for it in n.appearance.items.iter_mut() { - match it { - vectorcraft_doc::AppearanceItem::Fill(f) => fix(&mut f.paint), - vectorcraft_doc::AppearanceItem::Stroke(s) => fix(&mut s.paint), - } - } - } - } -} - -fn nodes_using(d: &Document, name: Option<&str>) -> Vec { - let mut ids = vec![]; - d.walk(|n| { - if pattern::uses_pattern(n, name) { - ids.push(n.id); - } - }); - ids + super::swatch::map_all_paints(d, &mut pattern_paints(old, Some(new))); } fn pattern_delete(s: &mut Session, p: &Value) -> Result { @@ -604,29 +658,8 @@ fn pattern_delete(s: &mut Session, p: &Value) -> Result { if editing(s).is_some_and(|e| e.pattern == name) { return Err(bad(C, "finish editing the pattern first")); } - let n = s.edit("Delete Pattern", |d, _| { - d.patterns.retain(|p| p.name != name); - let is_it = |sw: &vectorcraft_color::Swatch| matches!(&sw.paint, Paint::Pattern { pattern, .. } if *pattern == name); - d.swatches.retain(|sw| !is_it(sw)); - for g in &mut d.swatch_groups { - g.swatches.retain(|sw| !is_it(sw)); - } - let ids = nodes_using(d, Some(&name)); - for id in &ids { - if let Some(n) = d.node_mut(*id) { - for it in n.appearance.items.iter_mut() { - let p = match it { - vectorcraft_doc::AppearanceItem::Fill(f) => &mut f.paint, - vectorcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint, - }; - if matches!(p, Paint::Pattern { pattern, .. } if *pattern == name) { - *p = Paint::None; - } - } - } - } - Ok(ids.len()) - })?; + let n = s.edit("Delete Pattern", |d, _| Ok(forget_pattern(d, &name)))?; + map_new_art_paints(s, &mut pattern_paints(&name, None))?; Ok(json!({ "unpainted": n })) } diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 751313957f..748bcb4df2 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use serde_json::{Value, json}; use vectorcraft_color::{Color, Gradient, GradientKind, GradientMethod, GradientStop, Paint, Swatch, SwatchGroup, tint_percent}; use vectorcraft_doc::pattern::pattern_paint; -use vectorcraft_doc::swatches::{LinkFn, color_name, map_node_solid_paints, node_colors}; +use vectorcraft_doc::swatches::{LinkFn, PaintFn, color_name, map_node_paints, map_node_solid_paints, node_colors}; use vectorcraft_doc::{Document, NodeId}; use super::paint::paint_from; @@ -681,32 +681,16 @@ pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) { /// paint changed is merged into its entry ([`merge_stored`]), so fields the brush definition does /// not read are kept at every level. pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize { - use vectorcraft_brush::{Brush, BrushKind, Colorization}; - let mut n = d.map_solid_paints(f); - // Each stored entry, its brush when it parses, the brush as written before the edit (only - // when its paint changed), and whether its paint changed. - let mut lib: Vec<(Value, Option, Value, bool)> = match d.unknown.get(vectorcraft_brush::DOC_KEY) { - Some(v) => v.as_array().into_iter().flatten().map(|e| (e.clone(), Brush::from_json(e.clone()).ok(), Value::Null, false)).collect(), - None => { - vectorcraft_brush::library(d).into_iter().map(|b| (serde_json::to_value(&b).unwrap_or_default(), Some(b), Value::Null, false)).collect() - } - }; - let mut in_brushes = 0; - for (_, b, base, changed) in lib.iter_mut() { - let Some(b) = b else { continue }; - let before_edit = serde_json::to_value(&*b).unwrap_or_default(); + use vectorcraft_brush::{BrushKind, Colorization}; + let n = d.map_solid_paints(f); + n + edit_brush_library(d, &mut |b| { let key = match b.kind.colorization() { Colorization::HueShift { key } => Some(key), _ => None, }; let before = key.map(|_| art_paint_colors(b.art())); - let in_brush = b.art_mut().into_iter().map(|art| map_node_solid_paints(art, f)).sum::(); - *changed = in_brush > 0; - if *changed { - *base = before_edit; - } - in_brushes += in_brush; - let (Some(key), Some(before)) = (key, before) else { continue }; + let in_brush = b.art_mut().into_iter().map(|art| map_node_solid_paints(art, &mut *f)).sum::(); + let (Some(key), Some(before)) = (key, before) else { return in_brush }; // Colors are compared by hex value, as `art_colors` (Brush Options' key colors) lists them. let same = |a: &Color, b: &Color| a.to_hex() == b.to_hex(); let after = art_paint_colors(b.art()); @@ -719,10 +703,47 @@ pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize { BrushKind::Art(a) => &mut a.colorization, BrushKind::Scatter(s) => &mut s.colorization, BrushKind::Pattern(p) => &mut p.colorization, - BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => continue, + BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => return in_brush, }; *c = Colorization::HueShift { key }; } + in_brush + }) +} + +/// Apply a paint rewrite ([`Document::map_paints`]) to the document and to the art of the brushes +/// in its library ([`edit_brush_library`]). Returns the number of changed paints. +pub(super) fn map_all_paints(d: &mut Document, f: &mut PaintFn) -> usize { + let n = d.map_paints(f); + n + edit_brush_library(d, &mut |b| b.art_mut().into_iter().map(|art| map_node_paints(art, &mut *f)).sum()) +} + +/// Apply `edit` to each brush of the document's library, the stored entries or, with none stored, +/// the built-in library; `edit` returns how many paints of the brush's art it changed. The library +/// is stored again only when a brush's paint changed. Stored entries are stored again as they were +/// read, except that a brush whose paint changed is merged into its entry ([`merge_stored`]), so +/// fields the brush definition does not read are kept at every level. Returns the number of +/// changed paints. +fn edit_brush_library(d: &mut Document, edit: &mut dyn FnMut(&mut vectorcraft_brush::Brush) -> usize) -> usize { + use vectorcraft_brush::Brush; + // Each stored entry, its brush when it parses, the brush as written before the edit (only + // when its paint changed), and whether its paint changed. + let mut lib: Vec<(Value, Option, Value, bool)> = match d.unknown.get(vectorcraft_brush::DOC_KEY) { + Some(v) => v.as_array().into_iter().flatten().map(|e| (e.clone(), Brush::from_json(e.clone()).ok(), Value::Null, false)).collect(), + None => { + vectorcraft_brush::library(d).into_iter().map(|b| (serde_json::to_value(&b).unwrap_or_default(), Some(b), Value::Null, false)).collect() + } + }; + let mut in_brushes = 0; + for (_, b, base, changed) in lib.iter_mut() { + let Some(b) = b else { continue }; + let before_edit = serde_json::to_value(&*b).unwrap_or_default(); + let in_brush = edit(b); + *changed = in_brush > 0; + if *changed { + *base = before_edit; + } + in_brushes += in_brush; } if in_brushes > 0 { let lib = lib @@ -733,9 +754,8 @@ pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize { }) .collect(); d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(lib)); - n += in_brushes; } - n + in_brushes } /// `new` (a definition as written after an edit) with the parts of `raw` (its stored entry) that diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index cc545b179f..3ab6a7dcb8 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -27,7 +27,8 @@ pub struct HistoryEntry { pub(crate) named: Option, /// A number no other step takes ([`History::step`]). serial: u64, - /// Original and converted new-art colors, only for steps that change document color mode. + /// Original and changed new-art paints, only for steps that change them: a document color mode + /// change, and a pattern Cancel, Delete or rename that clears or renames a pattern paint. pub(crate) color_mode_paints: Option<(vectorcraft_tools::PaintDefaults, vectorcraft_tools::PaintDefaults)>, /// Owned new-art snapshots included in both directional estimates. color_mode_bytes: usize, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a891fb9599..2011b32419 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -216,9 +216,10 @@ pub struct Session { /// groups: for each step, the documents ([`DocState::uid`]) whose undo groups it belongs to, and /// the index of the document it left active when it changed the active document. pub(crate) batch_steps: Option, Option)>>, - /// After `command.batch` kept a color-mode step's new-art colors, until the top-level command - /// has inherited the selection's appearance: the documents ([`DocState::uid`]) whose newest - /// history step takes the colors the batch finally left. + /// After `command.batch` kept a color-mode step's new-art colors, or a pattern command recorded + /// the new-art paints it changed, until the top-level command has inherited the selection's + /// appearance: the documents ([`DocState::uid`]) whose newest history step takes the colors the + /// command finally left. pub(crate) batch_paint_docs: Vec, /// The composite fonts (lowercase names) that the open documents defined when the font /// database was last brought in step with them ([`cmd::compositefonts::sync`]). diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index 61a4cc8a01..b1db85f802 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -288,7 +288,10 @@ impl Session { st.revision += 1; } st.doc = it.doc.clone(); - let restore_paint = cmd == "command.batch" || it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch"); + // A batch, or a preview that changed the new-art paints (a pattern rename), started from + // the paints the interaction began with. + let restore_paint = + cmd == "command.batch" || it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch") || it.color_mode_paints.is_some(); let paint = it.paint.clone(); it.preview_paint = None; it.color_mode_paints = None; @@ -421,7 +424,7 @@ impl Session { if let Some(paint) = self .active() .and_then(|st| st.interaction.as_ref()) - .filter(|it| it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch")) + .filter(|it| it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch") || it.color_mode_paints.is_some()) .map(|it| it.paint.clone()) { self.paint = paint; @@ -431,6 +434,9 @@ impl Session { st.undo_interaction(); // Guides locked and slices locked or hidden during the interaction stay deselected. deselect_hidden(&menu, &st.doc, &mut st.selection); + // With New Art Has Basic Appearance off, new art takes the appearance of the selection as + // the document has it again, not as a preview left it. + self.inherit_new_art(); Ok(()) } diff --git a/crates/engine/src/tests/pattern.rs b/crates/engine/src/tests/pattern.rs index 0b948242db..c03284f60f 100644 --- a/crates/engine/src/tests/pattern.rs +++ b/crates/engine/src/tests/pattern.rs @@ -2,7 +2,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Paint; -use vectorcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType}; +use vectorcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType, pattern_paint}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; @@ -212,6 +212,186 @@ fn cancel_new_pattern_removes_it() { assert_eq!(doc(&s).layers.len(), 1); } +fn doc_mut(s: &mut Session) -> &mut vectorcraft_doc::Document { + Arc::make_mut(&mut s.doc_mut().unwrap().doc) +} + +fn is_pattern(p: &Paint, name: &str) -> bool { + matches!(p, Paint::Pattern { pattern, .. } if pattern == name) +} + +/// How many paints of pattern `name` the document holds: in the art and the definitions +/// ([`vectorcraft_doc::Document::visit_paints`]), the swatches, the character styles and the +/// stored brush library. +fn pattern_uses(d: &vectorcraft_doc::Document, name: &str) -> usize { + let mut n = 0; + d.visit_paints(&mut |p| n += usize::from(is_pattern(p, name))); + n += d.swatches_iter().filter(|w| is_pattern(&w.paint, name)).count(); + let styled = d.char_styles.iter().flat_map(|c| ["fill", "stroke"].map(|k| c.attrs.get(k).cloned())).flatten(); + n += styled.filter(|v| serde_json::from_value::(v.clone()).is_ok_and(|p| is_pattern(&p, name))).count(); + let needle = format!("\"pattern\":\"{name}\""); + n + d.unknown.get(vectorcraft_brush::DOC_KEY).map_or(0, |v| v.to_string().matches(&needle).count()) +} + +/// Canceling a new pattern removes every swatch of it, a color group's included, and the fills and +/// strokes of it on art outside the editing layer become no paint. The object's other paints, and +/// a swatch that only shares the pattern's name, stay. +#[test] +fn cancel_new_pattern_forgets_group_swatch_and_outside_fills_and_strokes() { + let mut s = session(); + let other = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setStroke", &json!({"ids": [other.0], "color": "#00ff00"})).unwrap(); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [other.0], "swatch": "Tiles"})).unwrap(); + let d = doc_mut(&mut s); + let solid = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + d.swatch_groups.push(vectorcraft_color::SwatchGroup { + name: "Mine".into(), + swatches: vec![ + vectorcraft_color::Swatch { name: "Grouped".into(), paint: pattern_paint("Tiles"), global: false, spot: false }, + vectorcraft_color::Swatch { name: "Tiles".into(), paint: solid.clone(), global: false, spot: false }, + ], + }); + assert!(pattern_uses(doc(&s), "Tiles") >= 3); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + let d = doc(&s); + assert!(d.pattern("Tiles").is_none()); + assert_eq!(pattern_uses(d, "Tiles"), 0); + assert!(d.swatch("Grouped").is_none(), "the group's pattern swatch goes"); + assert_eq!(d.swatch("Tiles").map(|w| &w.paint), Some(&solid), "a color swatch named like the pattern stays"); + let n = node(&s, other); + assert!(n.appearance.fill_paint().is_none()); + assert!(matches!(n.appearance.stroke_paint(), Paint::Solid { .. }), "the stroke stays"); +} + +/// Save a Copy keeps the copy as its own pattern when the new pattern is canceled, and the copy's +/// tile art loses its paints of the canceled pattern. +#[test] +fn cancel_new_pattern_forgets_its_paints_in_a_saved_copy() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap(); + let layer = doc(&s).pattern_edit.as_ref().unwrap().layer; + let tile = doc(&s).node(layer).unwrap().children().unwrap()[0].id; + s.execute("paint.setFill", &json!({"ids": [tile.0], "swatch": "Tiles"})).unwrap(); + assert_eq!(s.execute("object.pattern.saveCopy", &json!({})).unwrap()["name"], "Tiles copy"); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + let d = doc(&s); + let copy = d.pattern("Tiles copy").expect("the copy stays"); + assert_eq!(copy.art.len(), 1); + assert!(copy.art[0].appearance.fill_paint().is_none()); + assert_eq!(pattern_uses(d, "Tiles"), 0); +} + +/// Canceling a new pattern also clears its paints in type, opacity mask art, symbol definitions, +/// graph designs, graphic and character styles and brush art, and in the fill, stroke and +/// appearance new art takes, so new art does not take a paint of a pattern that is gone. +#[test] +fn cancel_new_pattern_forgets_hidden_paints_and_new_art_defaults() { + let mut s = session(); + let masked = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + let m = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, m.0]})).unwrap(); + let t = id_of(&s.execute("text.create", &json!({"x": 20, "y": 300, "text": "Hi"})).unwrap()); + let band = rect(&mut s, 200.0, 200.0, 40.0, 10.0); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [band.0], "swatch": "Tiles"})).unwrap(); + s.execute("brush.new", &json!({"type": "art", "name": "Band", "ids": [band.0]})).unwrap(); + let p = pattern_paint("Tiles"); + let d = doc_mut(&mut s); + let art = d.node(band).unwrap().clone(); + d.symbols.push(vectorcraft_doc::Symbol { name: "Sym".into(), art: Arc::new(art.clone()), dynamic: false }); + d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Bar".into(), art: Arc::new(art.clone()) }); + d.graphic_styles.push(vectorcraft_doc::GraphicStyle { + name: "Style".into(), + appearance: (*art.appearance).clone(), + id: 0, + opacity: 1.0, + blend: Default::default(), + isolate: false, + knockout: Default::default(), + unit_box: true, + }); + let attrs = json!({"fill": p, "stroke": p, "size": 12}); + d.char_styles.push(vectorcraft_doc::TextStyleDef { name: "Char".into(), attrs: attrs.as_object().unwrap().clone() }); + if let Some(NodeKind::Text(text)) = d.node_mut(t).map(|n| &mut n.kind) { + text.runs[0].style.fill = p.clone(); + } + let mask = d.node_mut(masked).and_then(|n| n.extras_if_any()).and_then(|e| e.mask.as_deref_mut()).expect("the mask"); + Arc::make_mut(&mut mask.art).appearance = vectorcraft_doc::Appearance::basic(p.clone(), Paint::None, 0.0).into(); + s.paint.fill = p.clone(); + s.paint.appearance = Some(vectorcraft_doc::Appearance::basic(Paint::None, p.clone(), 1.0)); + assert!(pattern_uses(doc(&s), "Tiles") >= 10, "{}", pattern_uses(doc(&s), "Tiles")); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + assert_eq!(pattern_uses(doc(&s), "Tiles"), 0); + assert!(doc(&s).char_styles[0].attrs["size"] == json!(12), "the style's other attributes stay"); + assert!(s.paint.fill.is_none()); + assert!(s.paint.appearance.as_ref().unwrap().items.iter().all(|it| match it { + vectorcraft_doc::AppearanceItem::Fill(f) => !is_pattern(&f.paint, "Tiles"), + vectorcraft_doc::AppearanceItem::Stroke(k) => !is_pattern(&k.paint, "Tiles"), + })); + let fresh = rect(&mut s, 300.0, 300.0, 20.0, 20.0); + let n = node(&s, fresh); + assert!(!is_pattern(&n.appearance.fill_paint(), "Tiles") && !is_pattern(&n.appearance.stroke_paint(), "Tiles")); +} + +/// Cancel is one undo step: Undo gives the pattern, its paints and the fill new art takes back, and +/// Redo clears them again. +#[test] +fn cancel_new_pattern_is_one_undo_step() { + let mut s = session(); + let other = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [other.0], "swatch": "Tiles"})).unwrap(); + s.paint.fill = pattern_paint("Tiles"); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + assert!(s.paint.fill.is_none() && doc(&s).pattern("Tiles").is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(is_pattern(&s.paint.fill, "Tiles")); + assert!(doc(&s).pattern("Tiles").is_some() && doc(&s).pattern_edit.is_some()); + assert!(is_pattern(&node(&s, other).appearance.fill_paint(), "Tiles")); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(s.paint.fill.is_none() && doc(&s).pattern("Tiles").is_none()); + assert_eq!(pattern_uses(doc(&s), "Tiles"), 0); +} + +/// Canceling the edit of a pattern that Pattern Options renamed while it was edited gives the +/// pattern its old name back, with its swatch and the paints of it, so nothing is left painted +/// with a name no pattern has. +#[test] +fn cancel_after_renaming_an_edited_pattern_restores_its_name_and_paints() { + let mut s = session(); + make_dots(&mut s); + let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); + s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap(); + s.execute("pattern.options", &json!({"newName": "Spots"})).unwrap(); + assert!(is_pattern(&node(&s, big).appearance.fill_paint(), "Spots")); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + let d = doc(&s); + assert!(d.pattern("Dots").is_some() && d.pattern("Spots").is_none()); + assert!(d.swatch("Dots").is_some_and(|w| is_pattern(&w.paint, "Dots"))); + assert!(is_pattern(&node(&s, big).appearance.fill_paint(), "Dots")); + assert_eq!(pattern_uses(d, "Spots"), 0); + // When Save a Copy has taken the old name since, the pattern keeps its new name and paints. + s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap(); + s.execute("pattern.options", &json!({"newName": "Spots"})).unwrap(); + s.execute("object.pattern.saveCopy", &json!({"name": "Dots"})).unwrap(); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + let d = doc(&s); + assert!(d.pattern("Dots").is_some() && d.pattern("Spots").is_some()); + assert!(is_pattern(&node(&s, big).appearance.fill_paint(), "Spots")); +} + #[test] fn edit_then_cancel_restores_definition() { let mut s = session(); @@ -381,6 +561,132 @@ fn delete_pattern_unpaints_objects() { assert!(s.execute("pattern.delete", &json!({"name": "Dots"})).is_err()); } +/// Puts paints of pattern `name` where the layers do not reach: a symbol definition, another +/// pattern's tile, a graphic style, a character style, a type run, a color group's swatch and the +/// fill new art takes. +fn paint_outside_the_layers(s: &mut Session, name: &str) { + let t = id_of(&s.execute("text.create", &json!({"x": 20, "y": 300, "text": "Hi"})).unwrap()); + let p = pattern_paint(name); + let d = doc_mut(s); + let shape = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)); + let art = Node::path(d.alloc_id(), shape, vectorcraft_doc::Appearance::basic(p.clone(), Paint::None, 0.0)); + d.symbols.push(vectorcraft_doc::Symbol { name: "Sym".into(), art: Arc::new(art.clone()), dynamic: false }); + d.patterns.push(vectorcraft_doc::pattern::PatternDef::new("Other", vec![Arc::new(art.clone())])); + d.graphic_styles.push(vectorcraft_doc::GraphicStyle { + name: "Style".into(), + appearance: (*art.appearance).clone(), + id: 0, + opacity: 1.0, + blend: Default::default(), + isolate: false, + knockout: Default::default(), + unit_box: true, + }); + let attrs = json!({"fill": p}); + d.char_styles.push(vectorcraft_doc::TextStyleDef { name: "Char".into(), attrs: attrs.as_object().unwrap().clone() }); + if let Some(NodeKind::Text(text)) = d.node_mut(t).map(|n| &mut n.kind) { + text.runs[0].style.fill = p.clone(); + } + d.swatch_groups.push(vectorcraft_color::SwatchGroup { + name: "Mine".into(), + swatches: vec![vectorcraft_color::Swatch { name: "Grouped".into(), paint: p.clone(), global: false, spot: false }], + }); + s.paint.fill = p; +} + +/// Delete Pattern clears the pattern's paints outside the layers too, and in the fill new art takes. +#[test] +fn delete_pattern_forgets_paints_outside_the_layers() { + let mut s = session(); + make_dots(&mut s); + paint_outside_the_layers(&mut s, "Dots"); + assert_eq!(pattern_uses(doc(&s), "Dots"), 7); + let v = s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap(); + assert_eq!(v["unpainted"], 5, "the symbol, the other tile, the graphic style, the character style and the type"); + assert_eq!(pattern_uses(doc(&s), "Dots"), 0); + assert!(doc(&s).swatch("Grouped").is_none() && doc(&s).pattern("Other").is_some()); + assert!(s.paint.fill.is_none()); +} + +/// A Pattern Options rename renames the pattern's paints outside the layers too, and in the fill +/// new art takes. +#[test] +fn rename_pattern_reaches_paints_outside_the_layers() { + let mut s = session(); + make_dots(&mut s); + paint_outside_the_layers(&mut s, "Dots"); + s.execute("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap(); + assert_eq!(pattern_uses(doc(&s), "Dots"), 0); + assert_eq!(pattern_uses(doc(&s), "Spots"), 7); + assert!(is_pattern(&s.paint.fill, "Spots")); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(is_pattern(&s.paint.fill, "Dots"), "Undo gives the fill its old name back"); +} + +/// With New Art Has Basic Appearance off, Redo of Delete Pattern gives new art the paints the +/// command left, the selected object's appearance taken after the pattern's fill was cleared. +#[test] +fn redo_of_delete_pattern_keeps_the_inherited_new_art_paints() { + let mut s = session(); + let r = make_dots(&mut s); + s.prefs.new_art_basic = false; + sel(&mut s, &[r]); + s.paint.fill = pattern_paint("Dots"); + s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap(); + let after = s.paint.clone(); + assert!(!is_pattern(&after.fill, "Dots")); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.paint, after); +} + +/// A Pattern Options rename tried as a preview changes the fill new art takes only while the +/// preview stands: another preview and Cancel start from the fill the interaction began with. +/// Keeping the preview keeps the rename, and Undo takes it back. +#[test] +fn rename_previews_restore_the_fill_new_art_takes() { + let mut s = session(); + make_dots(&mut s); + s.paint.fill = pattern_paint("Dots"); + s.begin_interaction("Pattern Options").unwrap(); + s.preview("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap(); + assert!(is_pattern(&s.paint.fill, "Spots")); + s.preview("pattern.options", &json!({"name": "Dots", "newName": "Tiles"})).unwrap(); + assert!(is_pattern(&s.paint.fill, "Tiles")); + s.cancel_interaction().unwrap(); + assert!(is_pattern(&s.paint.fill, "Dots") && doc(&s).pattern("Dots").is_some()); + s.begin_interaction("Pattern Options").unwrap(); + s.preview("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap(); + s.commit_interaction().unwrap(); + assert!(is_pattern(&s.paint.fill, "Spots") && doc(&s).pattern("Spots").is_some()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(is_pattern(&s.paint.fill, "Dots") && doc(&s).pattern("Dots").is_some()); +} + +/// With New Art Has Basic Appearance off, a canceled rename preview leaves new art with the +/// appearance of the selected object as the document has it again, so no paint of the previewed +/// name is left. +#[test] +fn canceled_rename_preview_leaves_no_inherited_paint_of_the_new_name() { + let mut s = session(); + make_dots(&mut s); + let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); + s.prefs.new_art_basic = false; + sel(&mut s, &[big]); + s.paint = vectorcraft_tools::PaintDefaults::default(); + s.begin_interaction("Pattern Options").unwrap(); + s.preview("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap(); + s.cancel_interaction().unwrap(); + assert!(doc(&s).pattern("Dots").is_some() && doc(&s).pattern("Spots").is_none()); + let items = s.paint.appearance.iter().flat_map(|a| a.items.iter()).map(|it| match it { + vectorcraft_doc::AppearanceItem::Fill(f) => f.paint.clone(), + vectorcraft_doc::AppearanceItem::Stroke(k) => k.paint.clone(), + }); + let paints: Vec = [s.paint.fill.clone(), s.paint.stroke.clone()].into_iter().chain(items).collect(); + assert!(paints.iter().all(|p| !is_pattern(p, "Spots")), "{paints:?}"); +} + #[test] fn transform_patterns_only_moves_placement() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index bf159f5f00..0280f5449b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4092,6 +4092,17 @@ the old name. A rename gives the new name to the pattern's swatches that had the old name keeps it. A blank name gives "New Pattern N" in `object.pattern.make` and "`` copy" in `object.pattern.saveCopy`, and `pattern.options` keeps the name. +`object.pattern.cancel {}` leaves pattern editing mode and discards the changes. For a pattern that `object.pattern.make` +made for the edit, it removes the pattern and every swatch of it, a color group's included. Every other paint of the +pattern becomes no paint: on art outside the editing layer, in opacity mask art, type runs, symbol definitions, graph +designs, other patterns' tiles, graphic and character styles and brush art, and in the fill, stroke and appearance new +art takes. A pattern that `object.pattern.saveCopy` saved during the edit stays, with those paints cleared in its tile. +For an existing pattern, Cancel puts the definition back as it was when editing started; when `pattern.options` renamed +it during the edit, the old name comes back with its swatches and paints, unless another pattern, swatch or color group +has taken that name. `pattern.delete {name}` clears the paints of the pattern in the same places and returns +`{unpainted}`, the number of document paints it cleared. A `pattern.options` rename renames the paints in the same +places. Undo and Redo restore the fill, stroke and appearance new art takes with the document. + The pattern commands (`object.pattern.make`, `object.pattern.edit`, `object.pattern.saveCopy`, `pattern.options`, `pattern.delete`) and the Repeat commands (`object.repeat.radial`, `object.repeat.grid`, `object.repeat.mirror`, `object.repeat.options`) reject a param of the wrong type, such as `"instances": "8"`, `"edit": "false"` or diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d5851d78ce..06ff166035 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -704,6 +704,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy). - **Names of new and renamed artboards, brushes and patterns:** `artboard.new` and `artboard.setProps` trim the white space around the name they are given, as the Artboards panel's rename does, and a blank name gives the artboard its default name, Artboard N for the Nth artboard. `brush.new`, `brush.duplicate`, `brush.options`, `object.pattern.make`, `object.pattern.saveCopy` and `pattern.options` trim the name as the Brush Options dialog and the Pattern Options panel do, and compare the trimmed name with the names there are; a blank name gives the name each command gives when there is none, and Brush Options and Pattern Options keep the name. Tests in `tests/panelcmds.rs`, `tests/brushsym.rs` and `tests/pattern.rs` cover each command. Public references: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html), [Brushes](https://web.archive.org/web/20251018101754/https://helpx.adobe.com/illustrator/using/brushes.html) and [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copies). - **Pattern names and swatch names:** `pattern.options` refuses a new name that another pattern, a swatch or a color group has. It takes a name that only the pattern's own swatches have. The reference page does not say what happens when the new name is taken; the refusal follows VectorCraft's rule that swatches and color groups share one namespace (`Document::swatch_name_taken`), which `swatch.new` and `swatch.edit` also follow. A rename gives the new name only to the pattern's swatches that had the old one; any other swatch with the old name keeps it, and art linked to that swatch stays linked. `object.pattern.make` and `object.pattern.saveCopy` number a color group's name as they number a swatch's. Tests in `tests/pattern.rs` cover each command. Public reference: [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copy). +- **Cancel, Delete and rename leave no paint of a missing pattern:** the help page lists Save a Copy, Done and Cancel for leaving pattern editing (https://helpx.adobe.com/illustrator/using/create-edit-patterns.html). Canceling a pattern made for the edit removes it with every swatch of it, a color group's included. Canceling, `pattern.delete` and a `pattern.options` rename reach every paint of the pattern: art outside the editing layer, opacity mask art, type runs, symbol definitions, graph designs, other patterns' tiles (a copy Save a Copy made included), graphic and character styles, brush art, and the fill, stroke and appearance new art takes. Cancel and Delete make those paints no paint, a VectorCraft rule; a rename renames them. Cancel after a rename in Pattern Options gives the pattern its old name back unless another pattern or swatch has taken it. Undo and Redo restore new art's paints with the document. Tests in `tests/pattern.rs` cover each command. - **A clip group in Properties:** with one clip group selected, the panel has Edit Clipping Path and Edit Contents at its top, under the Clip Group heading, which select the clipping path or the clipped art for the selection tools to transform alone, as Object › Clipping Mask does (: with a clip group selected, the tutorial uses Edit Clipping Path at the top of the Properties panel to select the mask for editing, and uses Edit Contents to select the masked art). Edit Contents shows only when the group clips something. Not checked against the reference app: whether Properties keeps the buttons while the clipping path or the contents is selected; the Control bar has no clip-group buttons here. Evidence: `panels::properties::tests::one_clip_group_has_edit_clipping_path_and_edit_contents_at_the_top`. - **Contextual controls in Properties:** the [Properties panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/properties-panel-overview.html) lists controls that appear for particular selections and tools; Properties has them, each running the existing command. Quick Actions has Blend Options for one selected blend, with the Blend tool or any other, as the [blending page](https://helpx.adobe.com/illustrator/using/blending-objects.html) describes (#4817) (the Blend Options dialog, OK one undo step) and Merge Live Paint when a Live Paint group is selected with more paths and nothing Live Paint refuses (`livePaint.merge`). Appearance has the stroke's Variable Width Profile list, the Control bar's, with the Width tool or when the stroke has a variable width (`stroke.set {profile}`), and beside fx the Delete icon: with one effect on the one selected object (its own or a fill's or stroke's) its name and an icon that removes it (`effect.remove`), with several an icon that opens the Appearance panel, as the overview describes. With Direct Selection and corners of one path selected, Corner and Corner Radius set the kind and radius of those corners (`object.setLiveShape`); a live rectangle's and polygon's own rows already do. Each edit is one undo step. The overview's Variable Font icon was already in Character. Not checked against the reference app: whether Merge needs two Live Paint groups (the overview speaks of several selected groups; `livePaint.merge` takes a group and paths), and where exactly each control sits in its section. Evidence: `panels::properties::tests::{blend_options_is_a_quick_action_for_one_blend_with_the_blend_tool, merge_live_paint_is_a_quick_action_for_a_live_paint_group_and_more, the_width_profile_shows_for_a_variable_width_or_the_width_tool, direct_selected_corners_show_their_kind_and_radius, one_effect_has_a_delete_icon_in_appearance}`. - **Edit Artboards in Properties:** a Transform section comes first (#5219): the reference point the Transform panel and Control bar share, the active artboard's X and Y (of that point) and W and H with the Constrain Width and Height Proportions link, its preset and orientation, and Move and Scale Artwork with Artboard. The Artboards section follows, with the name field, Artboard Options and Fill. The active artboard's name is a field. Typing a new name and pressing Enter or clicking elsewhere renames that artboard in one undo step, as the Artboards panel's rename does (`artboard.setProps {index, name}`); pressing Escape or leaving the field empty keeps the old name. A click on the Artboard Options icon beside the field opens Artboard Options for the active artboard, as double-clicking the Artboard tool's button does (`tool.options {tool: "artboard"}`). Pointer and key tests in `panels/properties.rs` cover both. Public references: [Resize artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/resize-artboards.html) and [Create and add new artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/add-new-artboards.html). From 600302c3a5c37e20ff4e9bfc0c78ec2dda814724 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:51:35 -0500 Subject: [PATCH 09/68] Graphic Styles list (#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..93b0241e1a 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 (Graphic Styles list fills 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 | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.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 d5851d78ce..cf141f7d5e 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 (`.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #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 (Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #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 | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.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 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 10/68] 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 11/68] 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 12/68] 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 13/68] 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 86a997d9282fc16b1a969d8a0c4fd60dd8dfebaf Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:44:48 -0500 Subject: [PATCH 14/68] PDF import (#6971): parity and roadmap entries --- docs/roadmap.md | 5 +++-- docs/target-app-parity.md | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 4c78d258c9..ac99ebe26f 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 (PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; 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 | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke instead of being left out, adding no text (#6971; `empty_actual_text_keeps_the_glyphs_art` in `crates/pdf/src/tests/actual_text.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. | @@ -148,7 +149,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 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) | +| 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 | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs (feature-valued alternates and unencoded glyphs applied in one Undo step while keeping Arabic joining, #5514), OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e2f536bdeb..cdf850e515 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 (Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #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 (PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #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 | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke instead of being left out, adding no text (#6971; `empty_actual_text_keeps_the_glyphs_art` in `crates/pdf/src/tests/actual_text.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. | @@ -509,7 +510,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | | Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812; distance and angle labels while drawing, #2983) | 2–4; status-bar X/Y uses the active ruler origin and General units | -| File formats | 6 | 85% | Explicit editing-data oversized single-artboard import fixed (#6865); scores and estimates unchanged. Affinity export, and the rest of Affinity import (adjustments, blur and bevel effects, brushes, live symbol instances, text threading), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | +| File formats | 6 | 85% | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); Explicit editing-data oversized single-artboard import fixed (#6865); scores and estimates unchanged. Affinity export, and the rest of Affinity import (adjustments, blur and bevel effects, brushes, live symbol instances, text threading), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done, as are the Japanese papers in its Media list: JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判, beside the ISO B4/B5 that settings saved before them keep) | 1–2 | | Automation | 3 | 65% | scripting surface, batch (MCP drops malformed app replies without replay; Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 | From 432fe0362fab8a3cd5600b8f7c11680f743eb131 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:45:12 -0500 Subject: [PATCH 15/68] Graphic Styles list (#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 93b0241e1a..d229b54a94 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 | Graphic Styles list fills 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 8e639a3ad7..b9d1e91416 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -497,7 +497,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | | Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (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 | +| Appearance, transparency, graphic styles, masks | 5 | 98% | Graphic Styles list fills a taller panel above its footer (#2992; scores and estimates unchanged); raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Rotate (Classic), Map Art and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (its engine, custom dictionary commands, the Edit Custom Dictionary dialog and Auto Spell Check are done, #4936: the Check Spelling dialog remains; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | 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 16/68] 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 17/68] 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 18/68] 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 19/68] 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 71da62dab2343c9b9da8a36505a61de31f57903b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 17:44:08 -0800 Subject: [PATCH 20/68] File formats: PICT pictures open and place File > Open and File > Place read QuickDraw pictures, versions 1 and 2, from .pct, .pic and .pict files, with or without the file's 512 bytes of application data; a version 2 picture also opens by its content. The reader plays the opcodes of Apple's Opcodes in Pictures table on the metafile player: lines, rectangles, rounded rectangles, ovals, arcs, polygons and regions become paths, text becomes point type in the font the picture names, bitmaps, pixel maps and QuickTime JPEG images become images, and the clip region clips what follows it. Place puts the picture in one group clipped to its frame. What isn't read comes back as warnings, and a picture cut short keeps what came before it. PICT is not written. Images whose transform flattens them no longer leave their pixel data in the document. Formats whose signature starts the file are detected before PICT, which is known by bytes 522 or 10. Docs: docs/mcp.md, docs/file-format-parity.md, docs/target-app-parity.md. --- crates/engine/src/cmd/fileio.rs | 6 +- crates/engine/src/cmd/fileio/commands.rs | 4 +- crates/engine/src/cmd/fileio/dxf.rs | 8 +- crates/engine/src/cmd/fileio/formats.rs | 12 +- crates/engine/src/cmd/fileio/load.rs | 10 + crates/engine/src/cmd/fileio/metafile.rs | 7 + crates/engine/src/cmd/fileio/tests_dxf.rs | 11 +- crates/engine/src/cmd/fileio/tests_pict.rs | 104 ++ crates/engine/src/cmd/place.rs | 6 +- crates/engine/tests/import_fuzz.rs | 103 +- crates/metafile/Cargo.toml | 2 +- crates/metafile/src/dib.rs | 6 +- crates/metafile/src/import.rs | 28 +- crates/metafile/src/import/pict.rs | 1173 ++++++++++++++++++++ crates/metafile/src/lib.rs | 28 +- crates/metafile/src/tests.rs | 1 + crates/metafile/src/tests/pict.rs | 466 ++++++++ crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/file-format-parity.md | 4 +- docs/mcp.md | 36 +- docs/target-app-parity.md | 1 + 42 files changed, 1997 insertions(+), 41 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/tests_pict.rs create mode 100644 crates/metafile/src/import/pict.rs create mode 100644 crates/metafile/src/tests/pict.rs diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index b7ff1c7734..333db1c68f 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -13,10 +13,10 @@ //! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend. //! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`. //! - `pdfimport`: the PDF pages, box and password `document.open` and Place read, `document.pdfInfo`. -//! - `dxf`: DXF export options, `document.exportDxf`, and the formats that can't be written (DWG, PICT). +//! - `dxf`: DXF export options, `document.exportDxf`, and the formats that can't be written (DWG, PageMaker). //! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`. //! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`. -//! - `metafile`: EMF and WMF export, open and place. +//! - `metafile`: EMF and WMF export, open and place; PICT open and place. //! - `hpgl`: HP-GL export options and plots. //! - `importer`: files that importer plug-ins open (`document.open {importer}`, and extensions no //! built-in format reads). @@ -217,6 +217,8 @@ mod tests_dxfimport; mod tests_hpgl; #[cfg(test)] mod tests_metafile; +#[cfg(test)] +mod tests_pict; #[cfg(test)] mod tests_pdfoutput; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index fe7c9d8f3e..26272d873d 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", always, load::open ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "File Formats", [], None, - "{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}, plugin?: the id of the exporter plug-in (write only) or importer plug-in (read only) that adds the format (its id is plugin.; see plugin.list)}], readable: [id…], writable: [id…], openExtensions: [ext…] (the importers' extensions that no built-in format reads included), unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}", + "{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}, plugin?: the id of the exporter plug-in (write only) or importer plug-in (read only) that adds the format (its id is plugin.; see plugin.list)}], readable: [id…], writable: [id…], openExtensions: [ext…] (the importers' extensions that no built-in format reads included), unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PageMaker, with what to use instead)}", always, formats ), diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index 0fcaa4bfc0..8486cd2226 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -1,6 +1,6 @@ //! DXF export for every DXF path (`document.export {format: dxf}`, `document.exportDxf`, Export As //! → DXF Options): the options parsed from the params, one drawing per artboard with Use -//! Artboards, and the formats that can't be written (DWG, PICT) with what to use instead. +//! Artboards, and the formats that can't be written (DWG, PageMaker) with what to use instead. use std::sync::Arc; @@ -106,12 +106,6 @@ pub const UNSUPPORTED: &[Unsupported] = &[ extensions: &["dwg"], hint: "DWG is not available (no openly licensed writer exists): export DXF instead, which CAD apps open", }, - Unsupported { - id: "pict", - label: "PICT", - extensions: &["pict", "pct", "pic"], - hint: "PICT is a retired format and is not supported: convert the file to PDF or PNG first", - }, Unsupported { id: "pmd", label: "PageMaker", diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 3492fd6051..75b202c8ab 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -520,6 +520,8 @@ pub const FORMATS: &[Format] = &[ reader("heif", "HEIC/HEIF", &["heic", "heif"], "image/heic", true), // jxl-oxide reads a JPEG XL file's first frame, which is stored as PNG. Exports don't write it. reader("jxl", "JPEG XL", &["jxl"], "image/jxl", true), + // QuickDraw pictures, read by `vectorcraft_metafile::import_pict` into paths, type and images. + reader("pict", "PICT", &["pct", "pic", "pict"], "image/x-pict", false), ]; /// Every extension `document.open` reads (the "All readable files" filter of open dialogs). @@ -564,6 +566,9 @@ pub const OPEN_EXTS: &[&str] = &[ "dxf", "emf", "wmf", + "pct", + "pic", + "pict", "plt", "hpgl", "hgl", @@ -581,10 +586,10 @@ pub const OPEN_EXTS: &[&str] = &[ /// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF or JPEG XL type, nor one for the `.pns`, `.dib` and `.rle` names of /// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS /// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it. -/// The packaging declares no type for HP-GL plot files either. +/// The packaging declares no type for HP-GL plot files or PICT pictures either. pub const UNASSOCIATED_EXTS: &[&str] = &[ "psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc", - "heic", "heif", "jxl", "plt", "hpgl", "hgl", "hpg", + "heic", "heif", "jxl", "plt", "hpgl", "hgl", "hpg", "pct", "pic", "pict", ]; /// The extension that picks each writable format when exporting (the format's first; PNG-8 shares @@ -658,6 +663,9 @@ pub const PLACE_EXTS: &[&str] = &[ "dxf", "emf", "wmf", + "pct", + "pic", + "pict", "plt", "hpgl", "hgl", diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 6f0f8fd533..76843f610e 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -132,6 +132,12 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> { if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) { return Some(f); } + // A version 2 PICT is known by its version opcodes after the picture's frame, at byte 522 of a + // file or byte 10 of a bare picture (version 1 by its file's name), so a format whose + // signature starts the file is checked first. + if vectorcraft_metafile::is_pict(bytes) { + return format("pict"); + } if clipboard::looks_like_svg(&String::from_utf8_lossy(&bytes[..bytes.len().min(4096)])) { return format("svg"); } @@ -499,6 +505,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let (doc, warnings) = super::metafile::import(bytes)?; (doc, warnings, false) } + "pict" => { + let (doc, warnings) = super::metafile::import_pict(bytes)?; + (doc, warnings, false) + } "hpgl" => { let i = vectorcraft_cad::hpgl::import(bytes).map_err(err)?; (i.document, i.warnings, false) diff --git a/crates/engine/src/cmd/fileio/metafile.rs b/crates/engine/src/cmd/fileio/metafile.rs index 6bc658a4a0..d987d82757 100644 --- a/crates/engine/src/cmd/fileio/metafile.rs +++ b/crates/engine/src/cmd/fileio/metafile.rs @@ -2,6 +2,7 @@ //! `document.export {format: emf|wmf}` and Export As (one picture of an artboard, or one per //! artboard with Use Artboards; raster effects as images, rendered at the document's raster //! effects resolution), `document.open` and File → Place (one artboard, the picture's frame). +//! QuickDraw pictures (PICT) open and place the same way; they are never written. use serde_json::Value; use vectorcraft_doc::Document; @@ -52,3 +53,9 @@ pub(super) fn import(bytes: &[u8]) -> Result<(Document, Vec)> { let i = vectorcraft_metafile::import(bytes).map_err(super::err)?; Ok((i.document, i.warnings)) } + +/// A QuickDraw picture's document and the import's warnings. +pub(super) fn import_pict(bytes: &[u8]) -> Result<(Document, Vec)> { + let p = vectorcraft_metafile::import_pict(bytes).map_err(super::err)?; + Ok((p.document, p.warnings)) +} diff --git a/crates/engine/src/cmd/fileio/tests_dxf.rs b/crates/engine/src/cmd/fileio/tests_dxf.rs index 03273d9450..1a3368b56f 100644 --- a/crates/engine/src/cmd/fileio/tests_dxf.rs +++ b/crates/engine/src/cmd/fileio/tests_dxf.rs @@ -1,5 +1,5 @@ //! DXF export through the commands: options, artboards, selection, images, and the formats that -//! can't be written (DWG, PICT). +//! can't be written (DWG, PageMaker). use serde_json::{Value, json}; @@ -275,23 +275,24 @@ fn formats_list_dxf_and_what_can_not_be_written() { } assert!(r["writable"].as_array().unwrap().contains(&json!("dxf"))); let unsupported: Vec<&str> = r["unsupported"].as_array().unwrap().iter().map(|u| u["id"].as_str().unwrap()).collect(); - assert_eq!(unsupported, ["dwg", "pict", "pmd"]); + assert_eq!(unsupported, ["dwg", "pmd"]); } #[test] -fn dwg_and_pict_say_what_to_use_instead() { +fn dwg_and_pagemaker_say_what_to_use_instead() { let mut s = two_boards(); for p in [json!({"format": "dwg"}), json!({"path": "/tmp/plan.DWG"})] { let e = s.execute("document.export", &p).unwrap_err().to_string(); assert!(e.contains("DWG is not available") && e.contains("DXF"), "{p}: {e}"); } + // PICT opens; it isn't written. let e = s.execute("document.export", &json!({"format": "pict"})).unwrap_err().to_string(); - assert!(e.contains("PICT is a retired format"), "{e}"); + assert!(e.contains("PICT files can be opened but not written"), "{e}"); let e = s .execute("document.open", &json!({"name": "old.pct", "dataBase64": vectorcraft_format::base64_encode(&[0u8; 600])})) .unwrap_err() .to_string(); - assert!(e.contains("old.pct") && e.contains("PICT"), "{e}"); + assert!(e.contains("not a PICT file"), "{e}"); let e = s .execute("document.open", &json!({"name": "site.dwg", "dataBase64": vectorcraft_format::base64_encode(b"AC1032\0\0")})) .unwrap_err() diff --git a/crates/engine/src/cmd/fileio/tests_pict.rs b/crates/engine/src/cmd/fileio/tests_pict.rs new file mode 100644 index 0000000000..513adffbef --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_pict.rs @@ -0,0 +1,104 @@ +//! PICT pictures through the commands: `document.open` by extension or by content, File › Place as +//! one group clipped to the picture's frame, the dialogs' filters and the format list, and files +//! that aren't pictures, or a PICT export, refused with a message. + +use serde_json::json; +use vectorcraft_doc::NodeKind; + +use super::*; + +fn b64(bytes: &[u8]) -> String { + vectorcraft_format::base64_encode(bytes) +} + +/// A version 2 PICT file of a 200 × 100 pt frame: a red rectangle and a line. +fn picture() -> Vec { + let words = |v: &[i16]| v.iter().flat_map(|x| x.to_be_bytes()).collect::>(); + let mut b = vec![0; 512]; + b.extend(words(&[0, 0, 0, 100, 200])); + b.extend([0x00, 0x11, 0x02, 0xFF, 0x0C, 0x00]); + b.extend(words(&[-1, 0])); + b.extend([0; 20]); + b.extend(words(&[0x1A, -1, 0, 0, 0x31, 10, 20, 50, 80, 0x20, 70, 10, 70, 190, 0xFF])); + b +} + +#[test] +fn document_open_reads_a_picture() { + let mut s = Session::new(); + let r = s.execute("document.open", &json!({"name": "logo.pct", "dataBase64": b64(&picture())})).unwrap(); + assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("pict"), Some("logo.pct")), "{r}"); + assert_eq!(r["warnings"], json!([])); + let st = s.doc().unwrap(); + assert_eq!(st.doc.artboards[0].rect.size(), (200.0, 100.0).into()); + let mut paths = vec![]; + st.doc.walk(|n| { + if let NodeKind::Path { .. } = n.kind { + paths.push(n.clone()); + } + }); + assert_eq!(paths.len(), 2); + let b = paths[0].geometric_bounds().unwrap(); + assert!((b.x0 - 20.0).abs() < 1e-9 && (b.width() - 60.0).abs() < 1e-9, "{b:?}"); + // Opened as artwork: Save asks for a name. + assert!(st.path.is_none()); + + // A picture opens by its content under a name no format reads, and in each of its names. + for name in ["logo.PICT", "logo.pic", "logo.bin", "logo"] { + let r = Session::new().execute("document.open", &json!({"name": name, "dataBase64": b64(&picture())})).unwrap(); + assert_eq!(r["format"].as_str(), Some("pict"), "{name}: {r}"); + } + assert_eq!(detect("logo.dat", &picture()).map(|f| f.id), Some("pict")); + assert_eq!(detect("bare.dat", &picture()[512..]).map(|f| f.id), Some("pict")); +} + +#[test] +fn file_place_places_a_picture_as_one_clipped_group() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + let p = json!({"name": "logo.pct", "dataBase64": b64(&picture()), "at": [150, 150]}); + let info = s.execute("file.place.info", &p).unwrap(); + assert_eq!(info["format"], "pict", "{info}"); + assert!((info["width"].as_f64().unwrap() - 200.0).abs() < 1e-9, "{info}"); + let r = s.execute("file.place", &p).unwrap(); + assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("pict"), Some(1)), "{r}"); + let doc = &s.doc().unwrap().doc; + let group = doc.node(vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap())).unwrap(); + assert_eq!(group.name.as_deref(), Some("logo.pct")); + let NodeKind::Group { clip: true, .. } = &group.kind else { panic!("a clipping group, got {:?}", group.kind) }; + let b = group.geometric_bounds().unwrap(); + assert!((b.width() - 200.0).abs() < 1e-6 && (b.height() - 100.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn files_that_are_not_pictures_are_refused() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + let e = s.execute("document.open", &json!({"name": "old.pct", "dataBase64": b64(&[0; 600])})).expect_err("refused"); + assert!(e.to_string().contains("not a PICT file"), "{e}"); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), (100.0, 50.0).into()); + assert!(s.execute("file.place", &json!({"name": "old.pct", "dataBase64": b64(&[0; 600])})).is_err()); + // Other formats' files aren't taken for pictures by their content. + // A file that starts with a PNG signature and holds a picture's version opcodes at byte 522. + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.extend(&picture()[8..]); + assert!(detect("x.png", &png).is_none_or(|f| f.id != "pict")); + let e = s.execute("document.export", &json!({"format": "pict"})).expect_err("refused").to_string(); + assert!(e.contains("PICT files can be opened but not written"), "{e}"); +} + +#[test] +fn the_dialogs_and_the_format_list_name_pict() { + let exts = ["pct", "pic", "pict"]; + for e in exts { + assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}"); + assert_eq!(format_for_name(&format!("a.{e}")).map(|f| f.id), Some("pict"), "{e}"); + } + let filter = ("PICT", &exts[..]); + assert!(open_filters().any(|f| f == filter) && place_filters().any(|f| f == filter)); + let r = Session::new().execute("document.formats", &json!({})).unwrap(); + let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "pict").unwrap().clone(); + assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(false), Some(false)), "{f}"); + assert_eq!(f["mime"], "image/x-pict"); + assert!(writable_format(None, Some("x/logo.pct")).is_err()); +} diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 5d56a2b646..81ec7fb7c2 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -6,8 +6,8 @@ //! `link` ([`super::links`]). //! - An SVG becomes one group of its art; so does a DXF drawing, read with the DXF options //! (`dxf`, [`fileio::dxfimport`]), and an HP-GL plot file (`vectorcraft_cad::hpgl`). -//! - An EMF or WMF picture becomes one group, clipped to its frame (`crop: "crop"`) or bounded by -//! its art (`crop: "bounding"`). +//! - An EMF, WMF or PICT picture becomes one group, clipped to its frame (`crop: "crop"`) or +//! bounded by its art (`crop: "bounding"`). //! - An EPS file (or a PostScript .ai) becomes one group, clipped to its bounding box or bounded by //! its art: the document an EPS file of ours carries, else what its PostScript draws, else its //! preview ([`fileio::load`]). @@ -69,7 +69,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 4688845de4..52be19a952 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3,7 +3,8 @@ //! (`.vcflattener`) and export preset (`.vcexportpresets`) libraries, shortcut sets and action sets other apps save as text (`.kys`, `.aia`), and native files (compressed, damaged, saved for older versions), //! must load as an error or as a document that then renders and exports, without a panic; nor may //! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records -//! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files +//! of every kind with random contents) opened, placed or pasted, nor PICT pictures (damaged files, +//! opcodes with random contents) opened or placed, nor EPS and PostScript files //! (damaged ones, hostile programs) read by the PostScript interpreter, nor the editing data of //! Illustrator EPS and `.ai` files (the layers they carry, damaged or hostile), nor Affinity documents //! (mutated object streams, archives and indexed PNG previews, hostile image dimensions), nor the @@ -2938,6 +2939,106 @@ proptest! { } } +// ---------- PICT pictures: opened and placed ---------- + +fn be16(v: &[i16]) -> Vec { + v.iter().flat_map(|x| x.to_be_bytes()).collect() +} + +/// A version 2 PICT file of a 200 × 100 pt frame holding `ops`, each padded to an even length. +fn pict_of(ops: &[(u16, Vec)]) -> Vec { + let mut b = vec![0; 512]; + b.extend(be16(&[0, 0, 0, 100, 200])); + b.extend([0x00, 0x11, 0x02, 0xFF, 0x0C, 0x00]); + b.extend(be16(&[-1, 0])); + b.extend([0; 20]); + for (op, data) in ops { + b.extend(op.to_be_bytes()); + b.extend(data); + if b.len() % 2 == 1 { + b.push(0); + } + } + b.extend([0x00, 0xFF]); + b +} + +/// A picture with shapes, a region, text, a pattern and a packed pixel map. +fn pict_sample() -> Vec { + let region = [be16(&[36, 0, 0, 20, 20]), be16(&[0, 0, 20, 0x7FFF, 10, 10, 20, 0x7FFF, 20, 0, 10, 0x7FFF, 0x7FFF])].concat(); + let table = be16(&[0, 0, 0, 1, 0, -1, 0, 0, 1, 0, 0, -1]); + let pixmap = [ + be16(&[0x8008u16 as i16, 0, 0, 2, 8]), + be16(&[0, 0, 0, 0, 72, 0, 72, 0, 0, 8, 1, 8, 0, 0, 0, 0, 0, 0]), + table, + be16(&[0, 0, 2, 8, 10, 20, 14, 36, 0]), + vec![2, 0xF9, 0, 9, 7, 1, 0, 1, 0, 1, 0, 1, 0], + ] + .concat(); + pict_of(&[ + (0x1A, be16(&[-1, 0, 0])), + (0x07, be16(&[2, 2])), + (0x31, be16(&[10, 20, 50, 80])), + (0x30, be16(&[10, 20, 50, 80])), + (0x0B, be16(&[8, 8])), + (0x41, be16(&[0, 100, 40, 160])), + (0x51, be16(&[50, 100, 90, 160])), + (0x61, be16(&[0, 0, 60, 60, 30, 120])), + (0x71, be16(&[22, 0, 0, 40, 40, 0, 0, 40, 40, 0, 40])), + (0x81, region.clone()), + (0x01, region), + (0x20, be16(&[70, 10, 70, 190])), + (0x21, be16(&[90, 100])), + (0x0A, vec![0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55]), + (0x34, be16(&[0, 0, 10, 10])), + (0x2C, [be16(&[9, 3]), vec![6], b"Geneva".to_vec()].concat()), + (0x0D, be16(&[12])), + (0x28, [be16(&[80, 10]), vec![3], b"abc".to_vec()].concat()), + (0x98, pixmap), + ]) +} + +/// The sample the mutations start from opens with art and an image. +#[test] +fn the_pict_sample_opens() { + let l = vectorcraft_engine::cmd::fileio::load("x.pct", &pict_sample()).unwrap(); + assert_eq!(l.format.id, "pict"); + let (mut paths, mut images, mut texts) = (0, 0, 0); + l.doc.walk(|n| match n.kind { + vectorcraft_doc::NodeKind::Path { .. } => paths += 1, + vectorcraft_doc::NodeKind::Image(_) => images += 1, + vectorcraft_doc::NodeKind::Text(_) => texts += 1, + _ => {} + }); + assert!(paths > 8 && images == 1 && texts == 1, "{paths} paths, {images} images, {texts} texts, warnings {:?}", l.warnings); +} + +/// PICT opcodes VectorCraft reads, and reserved ones it skips. +const PICT_OPS: [u16; 40] = [ + 0x01, 0x03, 0x04, 0x07, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x12, 0x13, 0x14, 0x1A, 0x1B, 0x20, 0x21, 0x22, 0x23, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x30, + 0x31, 0x38, 0x41, 0x51, 0x61, 0x71, 0x81, 0x90, 0x91, 0x98, 0x99, 0x9A, 0x9B, 0xA1, 0x8200, 0x0024, +]; + +proptest! { + #![proptest_config(config())] + + #[test] + fn mutated_pict_pictures_never_panic(cut in 0usize..4096, edits in prop::collection::vec((0usize..4096, any::()), 0..16)) { + let mut bytes = pict_sample(); + let n = bytes.len(); + for &(at, b) in &edits { + bytes[512 + at % (n - 512)] = b; + } + bytes.truncate(cut.max(512)); + survive_metafile("mutated PICT", "x.pct", &bytes)?; + } + + #[test] + fn random_pict_opcodes_never_panic(ops in prop::collection::vec((prop::sample::select(PICT_OPS.to_vec()), arb_body()), 0..32)) { + survive_metafile("PICT opcodes", "x.pct", &pict_of(&ops))?; + } +} + // ---------- HP-GL plot files: opened and placed ---------- /// A plot file with every kind of instruction the reader plays, printer escapes around it. diff --git a/crates/metafile/Cargo.toml b/crates/metafile/Cargo.toml index fe54a67f6e..01b5d6434a 100644 --- a/crates/metafile/Cargo.toml +++ b/crates/metafile/Cargo.toml @@ -3,7 +3,7 @@ name = "vectorcraft-metafile" version.workspace = true edition.workspace = true license.workspace = true -description = "VectorCraft Windows metafiles: hand-written EMF and WMF writers and readers" +description = "VectorCraft metafiles: hand-written EMF and WMF writers and readers, and a QuickDraw picture (PICT) reader" [lints] workspace = true diff --git a/crates/metafile/src/dib.rs b/crates/metafile/src/dib.rs index a0e95498c3..4aeb5b4381 100644 --- a/crates/metafile/src/dib.rs +++ b/crates/metafile/src/dib.rs @@ -11,8 +11,8 @@ const MAX_SIDE: u32 = 8192; /// Most pixels an image the writers store holds. const MAX_PIXELS: u64 = 1 << 24; /// Most pixels along a side, and in all, of a bitmap the reader decodes. -const MAX_READ_SIDE: u32 = 1 << 15; -const MAX_READ_PIXELS: u64 = 1 << 26; +pub(crate) const MAX_READ_SIDE: u32 = 1 << 15; +pub(crate) const MAX_READ_PIXELS: u64 = 1 << 26; /// Most pixels the bitmaps of one file may decode to together (four of the largest one), so a small /// file of many image records can't cost minutes of decoding (#3600). pub(crate) const MAX_FILE_PIXELS: u64 = 1 << 28; @@ -164,7 +164,7 @@ fn png_without_metadata(bytes: &[u8]) -> std::borrow::Cow<'_, [u8]> { } /// Take a bitmap of `w` × `h` from what is `left` of the file's budget, before it is decoded. -fn charge(w: u32, h: u32, left: &mut u64) -> Result<(), String> { +pub(crate) fn charge(w: u32, h: u32, left: &mut u64) -> Result<(), String> { let n = u64::from(w) * u64::from(h); *left = left.checked_sub(n).ok_or(OVER_BUDGET)?; Ok(()) diff --git a/crates/metafile/src/import.rs b/crates/metafile/src/import.rs index 0a3d791ee2..da7816663e 100644 --- a/crates/metafile/src/import.rs +++ b/crates/metafile/src/import.rs @@ -1,6 +1,7 @@ -//! The metafile reader: the format parsers (`emf`, `wmf`) play their records on a [`Player`], a -//! small model of the device context they draw into (objects, transforms, the current position, -//! the path bracket, the clip), which turns what they draw into document objects. +//! The metafile reader: the format parsers (`emf`, `wmf`, and `pict` for QuickDraw pictures) play +//! their records on a [`Player`], a small model of the device context they draw into (objects, +//! transforms, the current position, the path bracket, the clip), which turns what they draw into +//! document objects. //! //! Fills and strokes become paths with a fill and a stroke; lines drawn one after another with the //! same pen join into one path. Clips become clipping groups around what they clip. Images keep @@ -8,6 +9,7 @@ //! doesn't know are counted and skipped, with one warning. mod emf; +pub(crate) mod pict; mod wmf; use std::sync::Arc; @@ -787,15 +789,25 @@ impl Player { if img.width == 0 || img.height == 0 { return; } + let (w, h) = (f64::from(img.width), f64::from(img.height)); + self.image_xf(img, Affine::translate((dest.x0, dest.y0)) * Affine::scale_non_uniform(dest.width() / w, dest.height() / h), opacity); + } + + /// An image placed by `xf` (its pixels, from the top-left, → logical), at `opacity`. An image + /// that `xf` flattens or sends past finite numbers is left out, and adds nothing to the + /// document's images. + pub(crate) fn image_xf(&mut self, img: Rgba, xf: Affine, opacity: f64) { + if img.width == 0 || img.height == 0 { + return; + } + let xf = self.xf() * xf; + if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { + return; + } let Ok(png) = img.to_png() else { return self.warn(BAD_IMAGE) }; let blob = ImageBlob::new("image/png", png); let key = blob.content_key(); self.doc.images.entry(key.clone()).or_insert(blob); - let (w, h) = (f64::from(img.width), f64::from(img.height)); - let xf = self.xf() * Affine::translate((dest.x0, dest.y0)) * Affine::scale_non_uniform(dest.width() / w, dest.height() / h); - if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { - return; - } let im = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default(), pixels: None }; let mut n = Node::new(vectorcraft_doc::NodeId(0), NodeKind::Image(Box::new(im))); n.opacity = opacity.clamp(0.0, 1.0) as f32; diff --git a/crates/metafile/src/import/pict.rs b/crates/metafile/src/import/pict.rs new file mode 100644 index 0000000000..a9b4cae9da --- /dev/null +++ b/crates/metafile/src/import/pict.rs @@ -0,0 +1,1173 @@ +//! QuickDraw pictures (PICT, versions 1 and 2), played on the [`Player`] by the opcodes and data +//! lengths of Apple's *Imaging With QuickDraw*, appendix A. +//! +//! A PICT file holds 512 bytes of application data before the picture; a picture on its own (as +//! a `PICT` resource holds it) reads too. A unit is a point, and the artboard is the picture's +//! frame; an extended version 2 header's source rectangle is mapped onto the frame. +//! +//! - Lines, rectangles, rounded rectangles, ovals, arcs and wedges, polygons and regions become +//! paths. A framed shape is stroked inside its outline at the pen's size, and a line below and +//! to the right of its points, where the pen draws; painted, filled and erased shapes are filled +//! with the pen, fill or background pattern. A pattern of two colors is filled with their +//! average. +//! - Text becomes point type in the font the picture names, read as Mac Roman. +//! - Bitmaps and pixel maps (1 to 8 bits with their color table, 16 and 32 bits, packed or not) +//! become images, clipped to their mask region; QuickTime JPEG images become images. +//! - The clip region clips what follows it. +//! +//! What isn't read (inverted shapes, transfer modes other than a copy, other QuickTime formats) +//! comes back as one warning each. Apple's table gives every opcode's length, so opcodes that draw +//! nothing VectorCraft reads are skipped; a picture cut short keeps what came before. + +use std::collections::HashMap; +use std::io::Cursor; + +use vectorcraft_geom::{Affine, BezPath, FillRule, Point, Rect, Shape, Vec2, shapes}; + +use super::{BAD_IMAGE, BrushObj, FontObj, PS_GEOMETRIC, PenObj, Player, SRCCOPY, TOO_MANY_PIXELS, crop, round_rect}; +use crate::dib::{self, Rgba}; +use crate::{Imported, Kind}; + +/// What a file that holds no picture is refused with. +const NOT_PICT: &str = "not a PICT file"; +/// The application data before the picture in a PICT file. +const FILE_HEADER: usize = 512; +/// Most opcodes one picture plays (a small file can't keep the reader busy for minutes). +const MAX_OPS: usize = 1 << 22; +/// Most rectangles one region breaks into. +const MAX_RECTS: usize = 1 << 16; +/// Steps the regions of one picture may take to read (each inversion point and band costs some). +const MAX_REGION_WORK: u64 = 1 << 28; +/// Most bytes the unpacked rows of one image may hold. +const MAX_ROW_BYTES: u64 = 1 << 28; +/// Most bytes the unpacked rows of one picture's images and patterns may hold together. +const MAX_FILE_ROW_BYTES: u64 = 1 << 30; +/// Most bytes a row may hold past what its pixels need. +const ROW_SLACK: usize = 64; +/// Most pixels, and bytes of unpacked rows, a pixel pattern may hold. +const MAX_PATTERN_PIXELS: usize = 1 << 16; +const MAX_PATTERN_ROW_BYTES: usize = 1 << 20; +/// Curves made for ovals and arcs are this close to the true curve, points. +const TOLERANCE: f64 = 0.01; +/// GDI text alignment on the baseline, which QuickDraw's text location is. +const TA_BASELINE: u32 = 0x18; +/// Pen style flags: square and flat ends, mitered joins. +const SQUARE: u32 = 0x100; +const FLAT: u32 = 0x200; +const MITER: u32 = 0x2000; + +const CUT_SHORT: &str = "the picture is cut short or damaged: what came before was imported"; +const TOO_MANY_OPS: &str = "the picture holds more opcodes than one file may: the rest was left out"; +const INVERTED: &str = "inverted shapes were left out"; +const PATTERNS: &str = "patterns are filled with the average of their colors"; +const MODES: &str = "art drawn in transfer modes other than a copy is drawn as a copy"; +const FRAMED_REGIONS: &str = "framed regions are outlined rectangle by rectangle"; +const BIG_REGIONS: &str = "regions larger than one picture may hold were left out"; +const CODECS: &str = "QuickTime images in formats other than JPEG were left out"; +const NOTHING: &str = "the picture draws nothing VectorCraft reads"; + +/// Big-endian reads from a slice, never past its end. +struct Be<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Be<'a> { + fn new(data: &'a [u8], pos: usize) -> Self { + Self { data, pos } + } + fn bytes(&mut self, n: usize) -> Option<&'a [u8]> { + let b = self.data.get(self.pos..self.pos.checked_add(n)?)?; + self.pos += n; + Some(b) + } + fn array(&mut self) -> Option<[u8; N]> { + self.bytes(N)?.try_into().ok() + } + fn skip(&mut self, n: usize) -> Option<()> { + self.bytes(n).map(drop) + } + fn u8(&mut self) -> Option { + self.array().map(|[b]: [u8; 1]| b) + } + fn u16(&mut self) -> Option { + self.array().map(u16::from_be_bytes) + } + fn i16(&mut self) -> Option { + self.array().map(i16::from_be_bytes) + } + fn u32(&mut self) -> Option { + self.array().map(u32::from_be_bytes) + } + fn i32(&mut self) -> Option { + self.array().map(i32::from_be_bytes) + } + /// A QuickDraw point: vertical, then horizontal. + fn point(&mut self) -> Option { + let v = self.i16()?; + let h = self.i16()?; + Some(Point::new(h.into(), v.into())) + } + /// A size stored as a point (a pen or an oval: height, then width). + fn size(&mut self) -> Option { + self.point().map(Point::to_vec2) + } + /// A QuickDraw rectangle: top, left, bottom, right. + fn rect(&mut self) -> Option { + let (top, left, bottom, right) = (self.i16()?, self.i16()?, self.i16()?, self.i16()?); + Some(Rect::new(left.into(), top.into(), right.into(), bottom.into())) + } + /// An `RGBColor` (16 bits a component), 8 bits a component. + fn rgb(&mut self) -> Option<[u8; 3]> { + let [r, g, b] = [self.u16()?, self.u16()?, self.u16()?]; + Some([r, g, b].map(|c| (c >> 8) as u8)) + } +} + +/// Where a picture is, its frame and whether it is version 2. +struct Header { + frame: Rect, + v2: bool, + /// Where its first opcode starts. + ops: usize, +} + +/// The picture whose `picSize` is at `at`, if a version opcode follows its frame. +fn header(bytes: &[u8], at: usize) -> Option
{ + let mut r = Be::new(bytes, at.checked_add(2)?); + let frame = r.rect()?; + let (v2, ops) = match r.array::<2>()? { + [0x11, 0x01] => (false, r.pos), + [0x00, 0x11] if r.array::<4>()? == [0x02, 0xFF, 0x0C, 0x00] => (true, at + 10), + _ => return None, + }; + (frame.width() > 0.0 && frame.height() > 0.0).then_some(Header { frame, v2, ops }) +} + +/// The picture of a file (after its application data), else the picture `bytes` start with. +fn find(bytes: &[u8]) -> Option
{ + header(bytes, FILE_HEADER).or_else(|| header(bytes, 0)) +} + +/// Do `bytes` hold a version 2 picture, after a file's application data or on their own? The +/// version 1 signature is two bytes, too short to tell a file by. +pub(crate) fn is_pict(bytes: &[u8]) -> bool { + [FILE_HEADER, 0].into_iter().any(|at| header(bytes, at).is_some_and(|h| h.v2)) +} + +/// A pattern: eight rows of eight bits (set bits in the foreground color, the others in the +/// background color), or one color. +#[derive(Clone, Copy)] +enum Pat { + Bits([u8; 8]), + Rgb([u8; 3]), +} + +/// What a shape opcode does with its shape. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Verb { + Frame, + Paint, + Erase, + Invert, + Fill, +} + +/// The verb of a shape opcode (its low three bits; 5 to 7 are reserved). +fn verb(op: u16) -> Option { + match op & 7 { + 0 => Some(Verb::Frame), + 1 => Some(Verb::Paint), + 2 => Some(Verb::Erase), + 3 => Some(Verb::Invert), + 4 => Some(Verb::Fill), + _ => None, + } +} + +/// An old-style QuickDraw color (`blackColor` 33 … `blueColor` 409) as RGB. +fn old_color(c: u32) -> [u8; 3] { + match c { + 30 => [255; 3], + 69 => [255, 255, 0], + 137 => [255, 0, 255], + 205 => [255, 0, 0], + 273 => [0, 255, 255], + 341 => [0, 255, 0], + 409 => [0, 0, 255], + _ => [0; 3], + } +} + +/// `a` and `b` mixed: `t` of `a`. +fn mix(a: [u8; 3], b: [u8; 3], t: f64) -> [u8; 3] { + let m = |x: u8, y: u8| (f64::from(x) * t + f64::from(y) * (1.0 - t)).round() as u8; + [m(a[0], b[0]), m(a[1], b[1]), m(a[2], b[2])] +} + +/// Byte `b` of Mac Roman. +fn mac_roman(b: u8) -> char { + const HIGH: [char; 128] = [ + 'Ä', 'Å', 'Ç', 'É', 'Ñ', 'Ö', 'Ü', 'á', 'à', 'â', 'ä', 'ã', 'å', 'ç', 'é', 'è', // + 'ê', 'ë', 'í', 'ì', 'î', 'ï', 'ñ', 'ó', 'ò', 'ô', 'ö', 'õ', 'ú', 'ù', 'û', 'ü', // + '†', '°', '¢', '£', '§', '•', '¶', 'ß', '®', '©', '™', '´', '¨', '≠', 'Æ', 'Ø', // + '∞', '±', '≤', '≥', '¥', 'µ', '∂', '∑', '∏', 'π', '∫', 'ª', 'º', 'Ω', 'æ', 'ø', // + '¿', '¡', '¬', '√', 'ƒ', '≈', '∆', '«', '»', '…', '\u{A0}', 'À', 'Ã', 'Õ', 'Œ', 'œ', // + '–', '—', '“', '”', '‘', '’', '÷', '◊', 'ÿ', 'Ÿ', '⁄', '€', '‹', '›', 'fi', 'fl', // + '‡', '·', '‚', '„', '‰', 'Â', 'Ê', 'Á', 'Ë', 'È', 'Í', 'Î', 'Ï', 'Ì', 'Ó', 'Ô', // + '\u{F8FF}', 'Ò', 'Ú', 'Û', 'Ù', 'ı', 'ˆ', '˜', '¯', '˘', '˙', '˚', '¸', '˝', '˛', 'ˇ', + ]; + match b { + 0x80.. => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or(char::REPLACEMENT_CHARACTER), + _ => char::from(b), + } +} + +/// The family name of a classic Macintosh font number. +fn font_family(id: u16) -> &'static str { + match id { + 0 => "Chicago", + 1 | 3 => "Geneva", + 2 => "New York", + 4 => "Monaco", + 20 => "Times", + 21 => "Helvetica", + 22 => "Courier", + 23 => "Symbol", + _ => "", + } +} + +/// The arc of the oval in `r` from `start` degrees clockwise from 12 o'clock, over `sweep` +/// degrees (clockwise when positive). Angles are measured on the rectangle, so 45° runs through +/// its top-right corner. A wedge closes through the center. +fn arc(r: Rect, start: i16, sweep: i16, wedge: bool) -> BezPath { + let c = r.center(); + let radii = Vec2::new(r.width() / 2.0, r.height() / 2.0); + let mut bp = BezPath::new(); + if sweep == 0 { + return bp; + } + let a0 = (f64::from(start) - 90.0).to_radians(); + let sweep = f64::from(sweep).clamp(-360.0, 360.0).to_radians(); + let arc = kurbo::Arc { center: c, radii, start_angle: a0, sweep_angle: sweep, x_rotation: 0.0 }; + if wedge { + bp.move_to(c); + bp.line_to(c + Vec2::new(radii.x * a0.cos(), radii.y * a0.sin())); + arc.append_iter(TOLERANCE).for_each(|el| bp.push(el)); + bp.close_path(); + } else { + kurbo::Shape::path_elements(&arc, TOLERANCE).for_each(|el| bp.push(el)); + } + bp +} + +/// The outlines of `rects`, as one path. +fn rects_path(rects: &[Rect]) -> BezPath { + let mut bp = BezPath::new(); + for r in rects { + bp.extend(shapes::rectangle(*r).to_bezpath()); + } + bp +} + +/// Take `n` steps from `work`; false when too few are left. +fn spend(work: &mut u64, n: usize) -> bool { + match work.checked_sub(n as u64) { + Some(left) => { + *work = left; + true + } + None => false, + } +} + +/// A region (its size, bounding box and inversion points), read at `r`, as rectangles: `None` +/// when the data runs past the end; the flag is false when the region is larger than `work` or +/// [`MAX_RECTS`] allows, and then no rectangles are given. +fn region(r: &mut Be, work: &mut u64) -> Option<(Vec, bool)> { + let size = usize::from(r.u16()?); + let mut b = Be::new(r.bytes(size.checked_sub(2)?)?, 0); + let bbox = b.rect()?; + if size <= 10 { + let whole = bbox.width() > 0.0 && bbox.height() > 0.0; + return Some((if whole { vec![bbox] } else { vec![] }, true)); + } + Some(bands(&mut b, work).map_or((vec![], false), |rects| (rects, true))) +} + +/// The rectangles of a region's scanlines: each lists the x coordinates where being inside the +/// region changes from the scanline before. Bands of the same spans one after another are one +/// rectangle. +fn bands(b: &mut Be, work: &mut u64) -> Option> { + let mut out: Vec = vec![]; + let mut active: Vec = vec![]; + let mut last_band = 0; + let mut prev_y = 0.0; + while let Some(y) = b.i16().filter(|y| *y != 0x7FFF) { + let y = f64::from(y); + let spans: Vec<(f64, f64)> = active.as_chunks::<2>().0.iter().map(|[a, b]| (f64::from(*a), f64::from(*b))).collect(); + if !spans.is_empty() && y > prev_y { + if !spend(work, spans.len() + 1) { + return None; + } + let prev = out.get_mut(last_band..).unwrap_or_default(); + let same = prev.len() == spans.len() && prev.iter().zip(&spans).all(|(r, (x0, x1))| r.x0 == *x0 && r.x1 == *x1 && r.y1 == prev_y); + if same { + prev.iter_mut().for_each(|r| r.y1 = y); + } else { + last_band = out.len(); + out.extend(spans.iter().map(|&(x0, x1)| Rect::new(x0, prev_y, x1, y))); + if out.len() > MAX_RECTS { + return None; + } + } + } + prev_y = y; + while let Some(x) = b.i16().filter(|x| *x != 0x7FFF) { + if !spend(work, active.len() + 1) { + return None; + } + match active.binary_search(&x) { + Ok(i) => { + active.remove(i); + } + Err(i) => active.insert(i, x), + } + } + } + Some(out) +} + +/// Skip the data of opcode `op`, by the length Apple's table gives it. +fn skip(op: u16, r: &mut Be) -> Option<()> { + let n = match op { + 0x00 | 0x17..=0x19 | 0x1C | 0x1E | 0xB0..=0xCF | 0x8000..=0x80FF => 0, + 0x04 => 1, + 0x03 | 0x05 | 0x08 | 0x0D | 0x11 | 0x15 | 0x16 | 0xA0 => 2, + 0x06 | 0x07 | 0x0B | 0x0C | 0x0E | 0x0F => 4, + 0x1A | 0x1B | 0x1D | 0x1F => 6, + 0x02 | 0x09 | 0x0A | 0x10 => 8, + // Reserved opcodes with a 16-bit length, and the font name. + 0x24..=0x27 | 0x2C..=0x2F | 0x92..=0x97 | 0x9C..=0x9F | 0xA2..=0xAF => usize::from(r.u16()?), + // Reserved for Apple: two bytes for each unit of the high byte (the 24-byte header 0x0C00). + 0x0100..=0x7FFF => usize::from(op >> 8) * 2, + // 0xD0–0xFE and 0x8100–0xFFFF (QuickTime images among them): a 32-bit length. + _ => usize::try_from(r.u32()?).ok()?, + }; + r.skip(n) +} + +/// How a bitmap's or pixel map's pixels are stored. +struct PixMap { + row_bytes: usize, + bounds: Rect, + /// Bits a pixel. + size: u16, + pack: u16, + cmps: u16, + /// Indexed pixels' colors; a bitmap has none (its set bits are the foreground color). + palette: Option>, + /// Rows of 8 bytes or more may be packed with PackBits, as the packing says. + packed: bool, +} + +impl PixMap { + /// From `rowBytes` on: a pixel map when `rowBytes` has its high bit set or `pixmap` says so, + /// else a bitmap; a pixel map's color table follows unless `direct`. + fn read(r: &mut Be, pixmap: bool, direct: bool) -> Option { + let raw = r.u16()?; + let mut pm = PixMap { row_bytes: usize::from(raw & 0x3FFF), bounds: r.rect()?, size: 1, pack: 0, cmps: 1, palette: None, packed: true }; + if pixmap || raw & 0x8000 != 0 { + r.skip(2)?; + pm.pack = r.u16()?; + r.skip(14)?; + pm.size = r.u16()?; + pm.cmps = r.u16()?; + r.skip(14)?; + if !direct { + pm.palette = Some(color_table(r)?); + } + } + Some(pm) + } + + /// The width and height of its pixels. + fn dims(&self) -> Option<(usize, usize)> { + let (w, h) = (self.bounds.width(), self.bounds.height()); + (w > 0.0 && h > 0.0).then_some((w as usize, h as usize)) + } + + /// The packing: 0 is 4 for 32-bit pixels and 3 for 16-bit ones. + fn packing(&self) -> u16 { + match (self.size, self.pack) { + (32, 0) => 4, + (16, 0) => 3, + (_, p) => p, + } + } + + /// Rows are stored as they are (no byte count, no PackBits). + fn raw_rows(&self) -> bool { + !self.packed || self.row_bytes < 8 || self.packing() == 1 || (self.size == 32 && self.packing() == 2) + } + + /// Bytes of an unpacked row: a component plane each for packing 4, three bytes a pixel for + /// packing 2, else `rowBytes`. + fn row_len(&self, w: usize) -> usize { + match self.packing() { + 4 if self.size == 32 && self.row_bytes >= 8 => w * usize::from(self.cmps), + 2 if self.size == 32 => w * 3, + _ => self.row_bytes, + } + } + + /// Whether its pixels can be read: a depth QuickDraw has, and rows long enough for them and at + /// most [`ROW_SLACK`] bytes longer. + fn decodable(&self, w: usize) -> bool { + let need = match self.size { + 1 | 2 | 4 | 8 => (w * usize::from(self.size)).div_ceil(8), + 16 => 2 * w, + 32 if self.packing() == 4 && self.row_bytes >= 8 => { + if !matches!(self.cmps, 3 | 4) { + return false; + } + w * usize::from(self.cmps) + } + 32 if self.packing() == 2 => 3 * w, + 32 => 4 * w, + _ => return false, + }; + (need..=need + ROW_SLACK).contains(&self.row_len(w)) + } + + /// Read `h` rows of pixel data, unpacked into one buffer when `keep`, else only stepped over. + /// `None` when the data runs past the end; the flag is false when a row didn't unpack to its + /// length. + fn rows(&self, r: &mut Be, w: usize, h: usize, keep: bool) -> Option<(Vec, bool)> { + let len = self.row_len(w); + let unit = if self.packing() == 3 { 2 } else { 1 }; + let mut out = vec![]; + let mut ok = true; + let mut keep = keep; + for _ in 0..h { + if self.raw_rows() { + let row = r.bytes(len)?; + if keep { + out.extend_from_slice(row); + } + continue; + } + let n = if self.row_bytes > 250 { usize::from(r.u16()?) } else { usize::from(r.u8()?) }; + let packed = r.bytes(n)?; + // After a row that doesn't unpack, the rest are only stepped over. + if keep && !unpack(packed, len, unit, &mut out) { + ok = false; + keep = false; + out = vec![]; + } + } + Some((out, ok)) + } + + /// The color of pixel `x` of `row` (the foreground and background colors for a bitmap's bits). + fn pixel(&self, row: &[u8], x: usize, w: usize, fg: [u8; 3], bg: [u8; 3]) -> Option<[u8; 3]> { + let at = |i: usize| row.get(i).copied(); + match self.size { + 1 | 2 | 4 | 8 => { + let bits = usize::from(self.size); + let shift = 8 - bits - x * bits % 8; + let index = usize::from((at(x * bits / 8)? >> shift) & ((1u16 << bits) - 1) as u8); + match &self.palette { + Some(p) => p.get(index).copied(), + None => Some(if index == 0 { bg } else { fg }), + } + } + 16 => { + let v = u16::from_be_bytes([at(2 * x)?, at(2 * x + 1)?]); + let c = |s: u16| (((v >> s) & 31) * 255 / 31) as u8; + Some([c(10), c(5), c(0)]) + } + _ if self.packing() == 4 && self.row_bytes >= 8 => { + // Component planes, alpha first when there are four (QuickDraw doesn't draw it). + let first = usize::from(self.cmps) - 3; + Some([at(first * w + x)?, at((first + 1) * w + x)?, at((first + 2) * w + x)?]) + } + _ if self.packing() == 2 => Some([at(3 * x)?, at(3 * x + 1)?, at(3 * x + 2)?]), + _ => Some([at(4 * x + 1)?, at(4 * x + 2)?, at(4 * x + 3)?]), + } + } + + /// Unpacked rows as opaque pixels. + fn to_rgba(&self, data: &[u8], w: usize, h: usize, fg: [u8; 3], bg: [u8; 3]) -> Option { + let len = self.row_len(w); + let mut pixels = Vec::with_capacity(w * h * 4); + for y in 0..h { + let row = data.get(y * len..(y + 1) * len)?; + for x in 0..w { + pixels.extend(self.pixel(row, x, w, fg, bg)?); + pixels.push(255); + } + } + Some(Rgba { width: u32::try_from(w).ok()?, height: u32::try_from(h).ok()?, pixels }) + } +} + +/// A `ColorTable`: indexed pixels' colors (entry by entry for a device table, else by each +/// entry's value). +fn color_table(r: &mut Be) -> Option> { + r.skip(4)?; + let flags = r.u16()?; + let n = usize::from(r.u16()?) + 1; + let mut palette = vec![[0u8; 3]; 256]; + for i in 0..n { + let value = usize::from(r.u16()?); + let c = r.rgb()?; + if let Some(e) = palette.get_mut(if flags & 0x8000 != 0 { i } else { value }) { + *e = c; + } + } + Some(palette) +} + +/// Unpack PackBits `data` (runs of `unit`-byte elements) onto `out`, `len` bytes; false when the +/// data is damaged or short (the row is padded with zeros). +fn unpack(data: &[u8], len: usize, unit: usize, out: &mut Vec) -> bool { + let start = out.len(); + let mut i = 0; + let mut ok = true; + while out.len() - start < len { + let Some(&n) = data.get(i) else { + ok = false; + break; + }; + i += 1; + let n = n as i8; + if n >= 0 { + let k = (n as usize + 1) * unit; + let Some(lit) = data.get(i..i + k) else { + ok = false; + break; + }; + out.extend_from_slice(lit); + i += k; + } else if n != -128 { + let Some(v) = data.get(i..i + unit) else { + ok = false; + break; + }; + for _ in 0..(1 - isize::from(n)) { + out.extend_from_slice(v); + } + i += unit; + } + } + out.resize(start + len, 0); + ok +} + +/// The picture being played: QuickDraw's drawing state, and the player that makes the objects. +struct Pict { + p: Player, + frame: Rect, + /// Picture space → document. + base: Affine, + origin: Vec2, + fg: [u8; 3], + bg: [u8; 3], + pen: Vec2, + pen_mode: u16, + pen_pat: Pat, + fill_pat: Pat, + back_pat: Pat, + oval: Vec2, + pen_loc: Point, + font: u16, + face: u8, + text_size: i16, + text_loc: Point, + /// Font numbers the picture names. + names: HashMap, + /// The last rectangle of the rectangle, rounded rectangle, oval and arc opcodes. + last: [Rect; 4], + region_work: u64, + /// Bytes of unpacked rows the picture's images and patterns may still take. + row_bytes_left: u64, +} + +impl Pict { + fn new(frame: Rect) -> Self { + let base = Affine::translate(-frame.origin().to_vec2()); + let artboard = Rect::new(0.0, 0.0, frame.width(), frame.height()); + // A picture's coordinates map straight onto the document, as a WMF's do. + let p = Player::new(Kind::Wmf, base, 25.4 / 72.0, artboard); + Self { + p, + frame, + base, + origin: Vec2::ZERO, + fg: [0; 3], + bg: [255; 3], + pen: Vec2::new(1.0, 1.0), + pen_mode: 8, + pen_pat: Pat::Bits([0xFF; 8]), + fill_pat: Pat::Bits([0xFF; 8]), + back_pat: Pat::Bits([0; 8]), + oval: Vec2::ZERO, + pen_loc: Point::ZERO, + font: 0, + face: 0, + text_size: 0, + text_loc: Point::ZERO, + names: HashMap::new(), + last: [Rect::ZERO; 4], + region_work: MAX_REGION_WORK, + row_bytes_left: MAX_FILE_ROW_BYTES, + } + } + + /// Charge the `w` × `h` pixels of `pm`, and the bytes of its unpacked rows, to the picture's + /// budgets: false, and nothing charged, when either is spent. + fn charge(&mut self, pm: &PixMap, w: usize, h: usize) -> bool { + let bytes = (pm.row_len(w) as u64).saturating_mul(h as u64); + let Some(rows_left) = self.row_bytes_left.checked_sub(bytes) else { return false }; + let (Ok(wide), Ok(high)) = (u32::try_from(w), u32::try_from(h)) else { return false }; + if dib::charge(wide, high, &mut self.p.pixels_left).is_err() { + return false; + } + self.row_bytes_left = rows_left; + true + } + + /// Play opcode `op`, its data at `r`; `None` when the data runs past the end. + fn op(&mut self, op: u16, r: &mut Be) -> Option<()> { + if !matches!(op, 0x20..=0x23) { + self.p.flush(); + } + match op { + 0x01 => self.clip(r), + 0x0C => self.move_origin(r), + 0x20..=0x23 => self.line(op, r), + 0x28..=0x2B => self.text(op, r), + 0x2C => self.font_name(r), + 0x30..=0x6F => self.shape(op, r), + 0x70..=0x77 => self.poly(verb(op), r), + 0x80..=0x87 => self.region_shape(verb(op), r), + // The same polygon and region: Apple's table gives them no data and no drawing. + 0x78..=0x7F | 0x88..=0x8F => Some(()), + 0x90 | 0x91 | 0x98..=0x9B => self.bits(op, r), + 0xA1 => { + r.skip(2)?; + let n = usize::from(r.u16()?); + r.skip(n) + } + 0x0C00 => self.header(r), + 0x8200 => self.quicktime(r), + _ => self.state(op, r), + } + } + + /// The opcodes that set the drawing state; the others are skipped. + fn state(&mut self, op: u16, r: &mut Be) -> Option<()> { + match op { + 0x02 => self.back_pat = Pat::Bits(r.array()?), + 0x03 => self.font = r.u16()?, + 0x04 => self.face = r.u8()?, + 0x07 => self.pen = r.size()?, + 0x08 => self.pen_mode = r.u16()?, + 0x09 => self.pen_pat = Pat::Bits(r.array()?), + 0x0A => self.fill_pat = Pat::Bits(r.array()?), + 0x0B => self.oval = r.size()?, + 0x0D => self.text_size = r.i16()?, + 0x0E => self.fg = old_color(r.u32()?), + 0x0F => self.bg = old_color(r.u32()?), + 0x12 => self.back_pat = self.pix_pat(r)?, + 0x13 => self.pen_pat = self.pix_pat(r)?, + 0x14 => self.fill_pat = self.pix_pat(r)?, + 0x1A => self.fg = r.rgb()?, + 0x1B => self.bg = r.rgb()?, + _ => return skip(op, r), + } + Some(()) + } + + /// The version 2 header: an extended one's source rectangle maps onto the frame. + fn header(&mut self, r: &mut Be) -> Option<()> { + let mut b = Be::new(r.bytes(24)?, 0); + if b.i16() == Some(-2) + && let Some(src) = b.skip(10).and_then(|()| b.rect()) + && src.width() > 0.0 + && src.height() > 0.0 + { + let k = Vec2::new(self.frame.width() / src.width(), self.frame.height() / src.height()); + self.base = Affine::scale_non_uniform(k.x, k.y) * Affine::translate(-src.origin().to_vec2()); + self.p.dev_to_doc = self.base * Affine::translate(-self.origin); + } + Some(()) + } + + /// The origin moves by (dh, dv): a point drawn after it is that much nearer the top left. + fn move_origin(&mut self, r: &mut Be) -> Option<()> { + let (dh, dv) = (r.i16()?, r.i16()?); + self.origin += Vec2::new(dh.into(), dv.into()); + self.p.dev_to_doc = self.base * Affine::translate(-self.origin); + Some(()) + } + + /// A pixel pattern: its color (the average of a color pattern's pixels), else its bitmap. + fn pix_pat(&mut self, r: &mut Be) -> Option { + let kind = r.u16()?; + let bits: [u8; 8] = r.array()?; + match kind { + 1 => { + let pm = PixMap::read(r, true, false)?; + let (w, h) = pm.dims()?; + let small = w * h <= MAX_PATTERN_PIXELS && pm.row_len(w) * h <= MAX_PATTERN_ROW_BYTES; + let keep = small && pm.decodable(w) && self.charge(&pm, w, h); + let (data, _) = pm.rows(r, w, h, keep)?; + let avg = keep.then(|| pm.to_rgba(&data, w, h, self.fg, self.bg)).flatten().map(|img| self.average(&img)); + Some(avg.map_or(Pat::Bits(bits), Pat::Rgb)) + } + 2 => Some(Pat::Rgb(r.rgb()?)), + _ => Some(Pat::Bits(bits)), + } + } + + /// The average color of `img`, with a warning when its pixels differ. + fn average(&mut self, img: &Rgba) -> [u8; 3] { + let px = img.pixels.as_chunks::<4>().0; + if px.windows(2).any(|w| w[0] != w[1]) { + self.p.warn(PATTERNS); + } + let n = px.len().max(1) as u64; + let sum = |i: usize| (px.iter().map(|p| u64::from(p[i])).sum::() / n) as u8; + [sum(0), sum(1), sum(2)] + } + + /// The color a pattern fills with: its own, or its two colors mixed as its bits are. + fn pat_color(&mut self, pat: Pat) -> [u8; 3] { + match pat { + Pat::Rgb(c) => c, + Pat::Bits(b) => { + let ones: u32 = b.iter().map(|x| x.count_ones()).sum(); + if ones != 0 && ones != 64 { + self.p.warn(PATTERNS); + } + mix(self.fg, self.bg, f64::from(ones) / 64.0) + } + } + } + + /// The pen's color (a warning when its mode isn't a copy). + fn pen_color(&mut self) -> [u8; 3] { + if !matches!(self.pen_mode, 0 | 8) { + self.p.warn(MODES); + } + self.pat_color(self.pen_pat) + } + + /// The color painting, erasing or filling an area takes; `None` for the other verbs. + fn area_color(&mut self, verb: Verb) -> Option<[u8; 3]> { + match verb { + Verb::Paint => Some(self.pen_color()), + Verb::Erase => Some(self.pat_color(self.back_pat)), + Verb::Fill => Some(self.pat_color(self.fill_pat)), + Verb::Frame | Verb::Invert => None, + } + } + + fn has_pen(&self) -> bool { + self.pen.x > 0.0 && self.pen.y > 0.0 + } + + /// Fill `bp` (picture space) with `color`. + fn fill(&mut self, mut bp: BezPath, color: [u8; 3], rule: FillRule) { + bp.apply_affine(self.p.xf()); + self.p.dc.brush = BrushObj::Solid(color); + self.p.dc.fill_rule = rule; + self.p.draw(bp, true, false); + } + + /// The pen as a stroke of `color` with `ends`, as wide as the pen's average side. + fn set_pen(&mut self, color: [u8; 3], ends: u32) { + let width = (self.pen.x + self.pen.y) / 2.0; + self.p.dc.pen = PenObj { style: PS_GEOMETRIC | ends | MITER, width, color, entries: vec![] }; + } + + /// Stroke `bp` (picture space) with the pen in `color`. + fn stroke(&mut self, mut bp: BezPath, color: [u8; 3], ends: u32) { + self.set_pen(color, ends); + bp.apply_affine(self.p.xf()); + self.p.draw(bp, false, true); + } + + /// Draw a framed shape: its outline (from `outline` of a rectangle and an oval size) stroked + /// inside `r`, or filled when the pen is as large as the shape. + fn frame(&mut self, r: Rect, outline: impl Fn(Rect, Vec2) -> BezPath, ends: u32) { + if !self.has_pen() { + return; + } + let color = self.pen_color(); + let half = self.pen / 2.0; + let inner = Rect::new(r.x0 + half.x, r.y0 + half.y, r.x1 - half.x, r.y1 - half.y); + if inner.width() <= 0.0 || inner.height() <= 0.0 { + return self.fill(outline(r, self.oval), color, FillRule::NonZero); + } + self.stroke(outline(inner, self.oval - self.pen), color, ends); + } + + /// The rectangle, rounded rectangle, oval and arc opcodes. + fn shape(&mut self, op: u16, r: &mut Be) -> Option<()> { + let family = op >> 4; + let slot = usize::from(family - 3); + let rect = if op & 8 != 0 { self.last.get(slot).copied()? } else { r.rect()? }; + let angles = if family == 6 { Some((r.i16()?, r.i16()?)) } else { None }; + let Some(verb) = verb(op) else { return Some(()) }; + if let Some(l) = self.last.get_mut(slot) { + *l = rect; + } + if rect.width() <= 0.0 || rect.height() <= 0.0 { + return Some(()); + } + let outline = move |r: Rect, oval: Vec2, wedge: bool| match family { + 3 => shapes::rectangle(r).to_bezpath(), + 4 => round_rect(r, oval.x, oval.y), + 5 => kurbo::Ellipse::from_rect(r).to_path(TOLERANCE), + _ => angles.map_or_else(BezPath::new, |(start, sweep)| arc(r, start, sweep, wedge)), + }; + match verb { + Verb::Frame => self.frame(rect, |r, oval| outline(r, oval, false), if family == 6 { FLAT } else { SQUARE }), + Verb::Invert => self.p.warn(INVERTED), + _ => { + if let Some(c) = self.area_color(verb) { + self.fill(outline(rect, self.oval, true), c, FillRule::NonZero); + } + } + } + Some(()) + } + + /// A polygon opcode: framed along its points, else filled. + fn poly(&mut self, verb: Option, r: &mut Be) -> Option<()> { + let size = usize::from(r.u16()?); + let mut b = Be::new(r.bytes(size.checked_sub(2)?)?, 8); + let Some(verb) = verb else { return Some(()) }; + let mut pts = vec![]; + while let Some(p) = b.point() { + pts.push(p); + } + let Some((first, rest)) = pts.split_first() else { return Some(()) }; + let closed = pts.len() > 2 && pts.first() == pts.last(); + match verb { + Verb::Frame if self.has_pen() => { + let half = self.pen / 2.0; + let mut bp = BezPath::new(); + bp.move_to(*first + half); + rest.iter().for_each(|p| bp.line_to(*p + half)); + if closed { + bp.close_path(); + } + let color = self.pen_color(); + self.stroke(bp, color, SQUARE); + } + Verb::Frame => {} + Verb::Invert => self.p.warn(INVERTED), + _ => { + let mut bp = BezPath::new(); + bp.move_to(*first); + rest.iter().for_each(|p| bp.line_to(*p)); + bp.close_path(); + if let Some(c) = self.area_color(verb) { + self.fill(bp, c, FillRule::EvenOdd); + } + } + } + Some(()) + } + + /// A region's rectangles, with a warning when it was too large to read. + fn read_region(&mut self, r: &mut Be) -> Option> { + let (rects, whole) = region(r, &mut self.region_work)?; + if !whole { + self.p.warn(BIG_REGIONS); + } + Some(rects) + } + + /// A region opcode: each rectangle framed, else the region filled. + fn region_shape(&mut self, verb: Option, r: &mut Be) -> Option<()> { + let rects = self.read_region(r)?; + let Some(verb) = verb.filter(|_| !rects.is_empty()) else { return Some(()) }; + match verb { + Verb::Frame if self.has_pen() => { + if rects.len() > 1 { + self.p.warn(FRAMED_REGIONS); + } + let half = self.pen / 2.0; + let inner: Vec = rects.iter().map(|r| Rect::new(r.x0 + half.x, r.y0 + half.y, r.x1 - half.x, r.y1 - half.y)).collect(); + let color = self.pen_color(); + self.stroke(rects_path(&inner), color, SQUARE); + } + Verb::Frame => {} + Verb::Invert => self.p.warn(INVERTED), + _ => { + if let Some(c) = self.area_color(verb) { + self.fill(rects_path(&rects), c, FillRule::NonZero); + } + } + } + Some(()) + } + + /// The clip region: it replaces the clip. An empty region clips away everything after it, as + /// a clip of no area at the frame's corner; one too large to read leaves the clip as it was. + fn clip(&mut self, r: &mut Be) -> Option<()> { + let (mut rects, whole) = region(r, &mut self.region_work)?; + if !whole { + self.p.warn(BIG_REGIONS); + return Some(()); + } + if rects.is_empty() { + let corner = Point::new(self.frame.x0, self.frame.y0); + rects.push(Rect::from_points(corner, corner)); + } + let mut bp = rects_path(&rects); + bp.apply_affine(self.p.xf()); + self.p.push_clip(bp, FillRule::NonZero, true); + Some(()) + } + + /// The line opcodes: the pen drawn from point to point, hanging below and to the right of + /// the line; lines one after another join into one path. + fn line(&mut self, op: u16, r: &mut Be) -> Option<()> { + let delta = |r: &mut Be| Some(Vec2::new(f64::from(r.u8()? as i8), f64::from(r.u8()? as i8))); + let (from, to) = match op { + 0x20 => (r.point()?, r.point()?), + 0x21 => (self.pen_loc, r.point()?), + 0x22 => { + let a = r.point()?; + (a, a + delta(r)?) + } + _ => (self.pen_loc, self.pen_loc + delta(r)?), + }; + self.pen_loc = to; + if !self.has_pen() { + return Some(()); + } + let color = self.pen_color(); + if from == to { + // A dot: the pen's rectangle. + self.p.flush(); + self.fill(shapes::rectangle(Rect::from_points(from, from + self.pen)).to_bezpath(), color, FillRule::NonZero); + return Some(()); + } + let pen = PenObj { style: PS_GEOMETRIC | SQUARE | MITER, width: (self.pen.x + self.pen.y) / 2.0, color, entries: vec![] }; + if self.p.dc.pen != pen { + self.p.flush(); + self.p.dc.pen = pen; + } + let half = self.pen / 2.0; + let xf = self.p.xf(); + let (a, b) = (xf * (from + half), xf * (to + half)); + if self.p.pending.elements().last().and_then(|e| e.end_point()) != Some(a) { + self.p.pending.move_to(a); + } + self.p.pending.line_to(b); + Some(()) + } + + /// The text opcodes: a string at the text location, moved to or by the opcode's data. + fn text(&mut self, op: u16, r: &mut Be) -> Option<()> { + match op { + 0x28 => self.text_loc = r.point()?, + 0x29 => self.text_loc.x += f64::from(r.u8()?), + 0x2A => self.text_loc.y += f64::from(r.u8()?), + _ => { + self.text_loc.x += f64::from(r.u8()?); + self.text_loc.y += f64::from(r.u8()?); + } + } + let n = usize::from(r.u8()?); + let s: String = r.bytes(n)?.iter().map(|&b| mac_roman(b)).filter(|c| !c.is_control()).collect(); + let size = if self.text_size > 0 { self.text_size } else { 12 }; + let face = self.names.get(&self.font).cloned().unwrap_or_else(|| font_family(self.font).to_string()); + self.p.dc.font = FontObj { + height: -f64::from(size), + escapement: 0.0, + weight: if self.face & 1 != 0 { 700 } else { 400 }, + italic: self.face & 2 != 0, + face, + }; + self.p.dc.text_color = self.fg; + self.p.dc.text_align = TA_BASELINE; + self.p.text(self.text_loc, &s); + Some(()) + } + + /// A font's name for its number. + fn font_name(&mut self, r: &mut Be) -> Option<()> { + let n = usize::from(r.u16()?); + let mut b = Be::new(r.bytes(n)?, 0); + if let Some(id) = b.u16() + && let Some(len) = b.u8() + && let Some(name) = b.bytes(usize::from(len)) + { + let name: String = name.iter().map(|&c| mac_roman(c)).collect(); + self.names.insert(id, name.trim().to_string()); + } + Some(()) + } + + /// A bitmap or pixel map copied into a rectangle (`Rgn` opcodes: clipped to a mask region). + fn bits(&mut self, op: u16, r: &mut Be) -> Option<()> { + let direct = matches!(op, 0x9A | 0x9B); + if direct { + r.skip(4)?; + } + let mut pm = PixMap::read(r, direct, direct)?; + // BitsRect and BitsRgn store their rows unpacked. + pm.packed = op >= 0x98; + let (src, dst, mode) = (r.rect()?, r.rect()?, r.u16()?); + let mask = if op & 1 != 0 { Some(self.read_region(r)?) } else { None }; + let (w, h) = pm.dims()?; + let (wide, high) = (w as u64, h as u64); + let fits = wide * high <= dib::MAX_READ_PIXELS && pm.row_len(w) as u64 * high <= MAX_ROW_BYTES; + let readable = pm.decodable(w); + let keep = fits && readable && self.charge(&pm, w, h); + let (data, ok) = pm.rows(r, w, h, keep)?; + if !keep { + self.p.warn(if readable { TOO_MANY_PIXELS } else { BAD_IMAGE }); + return Some(()); + } + let Some(mut img) = pm.to_rgba(&data, w, h, self.fg, self.bg).filter(|_| ok) else { + self.p.warn(BAD_IMAGE); + return Some(()); + }; + self.transfer(&mut img, mode, pm.size == 1 && pm.palette.is_none()); + if dst.width() <= 0.0 || dst.height() <= 0.0 || mask.as_ref().is_some_and(Vec::is_empty) { + return Some(()); + } + let saved = self.p.dc.clips.clone(); + if let Some(rects) = &mask { + let mut bp = rects_path(rects); + bp.apply_affine(self.p.xf()); + self.p.push_clip(bp, FillRule::NonZero, false); + } + self.p.image(Ok(img), src - pm.bounds.origin().to_vec2(), dst, SRCCOPY, 1.0); + self.p.dc.clips = saved; + Some(()) + } + + /// Transfer mode `mode` on copied pixels: OR of a bitmap and the transparent mode leave the + /// background color out; the others are copied, with a warning. + fn transfer(&mut self, img: &mut Rgba, mode: u16, bitmap: bool) { + // 64 dithers, which doesn't change what is copied. + let clear = match mode & !64 { + 0 => false, + 1 if bitmap => true, + 36 => true, + _ => { + self.p.warn(MODES); + false + } + }; + if clear { + let bg = self.bg; + img.pixels.as_chunks_mut::<4>().0.iter_mut().filter(|p| [p[0], p[1], p[2]] == bg).for_each(|p| p[3] = 0); + } + } + + /// A compressed QuickTime image: a JPEG one becomes an image placed by its matrix. + fn quicktime(&mut self, r: &mut Be) -> Option<()> { + let n = usize::try_from(r.u32()?).ok()?; + let data = r.bytes(n)?; + match qt_image(data) { + Some((ctype, xf, src, image)) if &ctype == b"jpeg" => match self.jpeg(image) { + Ok(img) => { + let img = crop(img, src); + self.p.image_xf(img, xf * Affine::translate(src.origin().to_vec2()), 1.0); + } + Err(w) => self.p.warn(w), + }, + Some(_) => self.p.warn(CODECS), + None => self.p.warn(BAD_IMAGE), + } + Some(()) + } + + /// A JPEG stream's pixels, taken from the file's pixel budget. + fn jpeg(&mut self, data: &[u8]) -> Result { + let reader = || image::ImageReader::with_format(Cursor::new(data), image::ImageFormat::Jpeg); + let (w, h) = reader().into_dimensions().map_err(|_| BAD_IMAGE)?; + if w > dib::MAX_READ_SIDE || h > dib::MAX_READ_SIDE || u64::from(w) * u64::from(h) > dib::MAX_READ_PIXELS { + return Err(TOO_MANY_PIXELS); + } + dib::charge(w, h, &mut self.p.pixels_left).map_err(|_| TOO_MANY_PIXELS)?; + let img = reader().decode().map_err(|_| BAD_IMAGE)?.to_rgba8(); + Ok(Rgba { width: img.width(), height: img.height(), pixels: img.into_raw() }) + } +} + +/// A QuickTime image's compressor type, matrix (image → picture space), source rectangle and +/// compressed data, from its header and image description. +fn qt_image(d: &[u8]) -> Option<([u8; 4], Affine, Rect, &[u8])> { + let mut b = Be::new(d, 2); + let mut m = [0.0; 9]; + for v in &mut m { + *v = f64::from(b.i32()?) / 65536.0; + } + let matte = usize::try_from(b.u32()?).ok()?; + b.skip(10)?; + let src = b.rect()?; + b.skip(4)?; + let mask = usize::try_from(b.u32()?).ok()?; + b.skip(matte)?; + b.skip(mask)?; + let start = b.pos; + let id_size = usize::try_from(b.u32()?).ok()?; + let ctype = b.array::<4>()?; + b.skip(36)?; + let size = usize::try_from(b.u32()?).ok()?; + let at = start.checked_add(id_size)?; + let data = d.get(at..at.checked_add(size)?).or_else(|| d.get(at..))?; + // The rows of a QuickTime matrix are [a b u] [c d v] [tx ty w]; u, v and w (perspective) are + // left out. + let xf = Affine::new([m[0], m[1], m[3], m[4], m[6], m[7]]); + Some((ctype, xf, src, data)) +} + +/// Read a picture. +pub(crate) fn play(bytes: &[u8]) -> Result { + let h = find(bytes).ok_or(NOT_PICT)?; + let mut q = Pict::new(h.frame); + let mut r = Be::new(bytes, h.ops); + // Why the picture stopped before its end opcode, if it did. + let mut stopped = Some(TOO_MANY_OPS); + for _ in 0..MAX_OPS { + // Version 2 opcodes start on even bytes. + if h.v2 && r.pos % 2 == 1 && r.skip(1).is_none() { + stopped = Some(CUT_SHORT); + break; + } + let Some(op) = (if h.v2 { r.u16() } else { r.u8().map(u16::from) }) else { + stopped = Some(CUT_SHORT); + break; + }; + if op == 0x00FF { + stopped = None; + break; + } + if q.op(op, &mut r).is_none() { + stopped = Some(CUT_SHORT); + break; + } + } + if let Some(why) = stopped { + q.p.warn(why); + } + q.p.flush(); + if q.p.out.is_empty() { + q.p.warn(NOTHING); + } + Ok(q.p.finish()) +} diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index a517d516a6..0e699236d7 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -15,13 +15,14 @@ //! //! [`import`] reads either format back into a document of one artboard (the picture's frame) and //! one layer: paths with fills and strokes, clipping, images and text. Records it doesn't read are -//! skipped, with one warning. +//! skipped, with one warning. [`import_pict`] reads QuickDraw pictures (PICT, versions 1 and 2) +//! into the same kind of document. //! //! - `bytes`: little-endian writing and bounds-checked reading. //! - `dib`: device-independent bitmaps, both ways. //! - `scene`: the document walk both writers share. //! - `emf`, `wmf`: the writers. -//! - `import`: the reader. +//! - `import`: the readers. mod bytes; mod dib; @@ -107,5 +108,28 @@ pub fn import(bytes: &[u8]) -> Result { Ok(i) } +/// A QuickDraw picture read into a document. +#[derive(Clone, Debug)] +pub struct Picture { + pub document: Document, + /// What was approximated or skipped. + pub warnings: Vec, +} + +/// Do `bytes` hold a version 2 QuickDraw picture, after a PICT file's 512 bytes of application +/// data or on their own? A version 1 picture is known by its file's name. +pub fn is_pict(bytes: &[u8]) -> bool { + import::pict::is_pict(bytes) +} + +/// Read a QuickDraw picture (version 1 or 2; a PICT file, or a picture on its own) into a document +/// whose one artboard is the picture's frame. +pub fn import_pict(bytes: &[u8]) -> Result { + let i = import::pict::play(bytes)?; + let mut p = Picture { document: i.document, warnings: i.warnings }; + p.document.limit_depth_noting(&mut p.warnings); + Ok(p) +} + #[cfg(test)] mod tests; diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 23bcfdc672..602db3d55f 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -12,6 +12,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes}; use crate::*; mod import; +mod pict; /// Add `n` (with a fresh id) on top of `d`'s first layer. pub(crate) fn add(d: &mut Document, mut n: Node) { diff --git a/crates/metafile/src/tests/pict.rs b/crates/metafile/src/tests/pict.rs new file mode 100644 index 0000000000..055e88c95b --- /dev/null +++ b/crates/metafile/src/tests/pict.rs @@ -0,0 +1,466 @@ +//! QuickDraw pictures read from hand-built opcodes: versions 1 and 2, with and without the file's +//! application data; shapes, lines, regions, patterns, text, bitmaps, pixel maps and QuickTime +//! JPEG images; and pictures that are cut short, damaged or never end. + +use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind}; +use vectorcraft_geom::{Point, Rect}; + +use crate::import_pict; + +/// Big-endian words. +fn be(v: &[i16]) -> Vec { + v.iter().flat_map(|x| x.to_be_bytes()).collect() +} + +/// A QuickDraw rectangle: top, left, bottom, right. +fn rect(top: i16, left: i16, bottom: i16, right: i16) -> Vec { + be(&[top, left, bottom, right]) +} + +/// A version 2 PICT file: 512 bytes of application data, then a picture of `frame` (top, left, +/// bottom, right) holding `ops`, each padded to an even length, and the end opcode. +fn v2(frame: [i16; 4], ops: &[(u16, Vec)]) -> Vec { + let mut b = vec![0xA5; 512]; + b.extend([0, 0]); + b.extend(be(&frame)); + b.extend([0x00, 0x11, 0x02, 0xFF, 0x0C, 0x00]); + b.extend(be(&[-1, 0])); + b.extend([0; 20]); + for (op, data) in ops { + b.extend(op.to_be_bytes()); + b.extend(data); + if b.len() % 2 == 1 { + b.push(0); + } + } + b.extend([0x00, 0xFF]); + b +} + +/// An RGB foreground color. +fn fg(r: u16, g: u16, b: u16) -> (u16, Vec) { + (0x1A, [r, g, b].iter().flat_map(|c| c.to_be_bytes()).collect()) +} + +/// Every object of the imported document, depth first. +fn all(d: &Document) -> Vec { + let mut v = vec![]; + for l in &d.layers { + for c in l.children().into_iter().flatten() { + c.walk(&mut |n| v.push(n.clone())); + } + } + v +} + +fn paths(d: &Document) -> Vec { + all(d).into_iter().filter(|n| matches!(n.kind, NodeKind::Path { clipping: false, .. })).collect() +} + +fn near(a: Rect, b: Rect, tol: f64) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() <= tol) +} + +fn fill_rgb(n: &Node) -> Option<[u8; 3]> { + n.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) => f.paint.color().map(|c| { + let [r, g, b, _] = c.to_rgba8(1.0); + [r, g, b] + }), + _ => None, + }) +} + +fn stroke(n: &Node) -> Option<([u8; 3], f64)> { + n.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Stroke(s) => s.paint.color().map(|c| { + let [r, g, b, _] = c.to_rgba8(1.0); + ([r, g, b], s.width) + }), + _ => None, + }) +} + +/// The images of `d` with their pixels. +fn images(d: &Document) -> Vec<(Node, image::RgbaImage)> { + all(d) + .into_iter() + .filter_map(|n| match &n.kind { + NodeKind::Image(im) => { + let png = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8(); + Some((n.clone(), png)) + } + _ => None, + }) + .collect() +} + +#[test] +fn a_version_2_picture_opens_as_shapes_in_its_frame() { + let bytes = v2( + [10, 20, 110, 220], + &[ + fg(0xFFFF, 0, 0), + // paintRect + (0x31, rect(20, 30, 60, 80)), + (0x07, be(&[4, 4])), + fg(0, 0, 0xFFFF), + // frameRect: stroked inside the rectangle. + (0x30, rect(20, 120, 80, 200)), + // A line, and one from its end: one path. + (0x20, be(&[90, 40, 90, 140])), + (0x21, be(&[100, 140])), + ], + ); + assert!(crate::is_pict(&bytes)); + let p = import_pict(&bytes).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + let a = p.document.artboards[0].rect; + assert!(near(a, Rect::new(0.0, 0.0, 200.0, 100.0), 1e-9), "{a:?}"); + let v = paths(&p.document); + assert_eq!(v.len(), 3, "{v:?}"); + assert_eq!(fill_rgb(&v[0]), Some([255, 0, 0])); + assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 60.0, 50.0), 1e-6)); + assert!(v[0].appearance.stroke().is_none()); + // The pen's outer edge is the rectangle. + assert_eq!(stroke(&v[1]), Some(([0, 0, 255], 4.0))); + assert!(near(v[1].geometric_bounds().unwrap(), Rect::new(102.0, 12.0, 178.0, 68.0), 1e-6)); + assert!(fill_rgb(&v[1]).is_none()); + // The pen hangs below and to the right of the line. + assert_eq!(stroke(&v[2]), Some(([0, 0, 255], 4.0))); + assert!(near(v[2].geometric_bounds().unwrap(), Rect::new(22.0, 82.0, 122.0, 92.0), 1e-6)); +} + +#[test] +fn a_bare_picture_and_a_version_1_picture_open() { + // A version 2 picture without the file's application data. + let file = v2([0, 0, 50, 50], &[(0x31, rect(10, 10, 20, 20))]); + let bare = &file[512..]; + assert!(crate::is_pict(bare)); + assert_eq!(paths(&import_pict(bare).unwrap().document).len(), 1); + // Version 1: byte opcodes, known by name only. + let mut v1 = vec![0, 0]; + v1.extend(rect(0, 0, 40, 60)); + v1.extend([0x11, 0x01, 0x31]); + v1.extend(rect(5, 5, 15, 25)); + v1.extend([0xA0, 0x00, 0x05, 0x50]); + v1.extend(rect(20, 20, 30, 30)); + v1.push(0xFF); + assert!(!crate::is_pict(&v1)); + let p = import_pict(&v1).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + assert_eq!(p.document.artboards[0].rect.size(), (60.0, 40.0).into()); + let v = paths(&p.document); + assert_eq!(v.len(), 2); + assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(5.0, 5.0, 25.0, 15.0), 1e-6)); + // The default pen: one point, black. + assert_eq!(stroke(&v[1]), Some(([0, 0, 0], 1.0))); + // Inside a file's application data too. + let mut file = vec![0; 512]; + file.extend(&v1); + assert_eq!(paths(&import_pict(&file).unwrap().document).len(), 2); +} + +#[test] +fn files_that_are_not_pictures_are_refused() { + assert_eq!(import_pict(&[0; 600]).unwrap_err(), "not a PICT file"); + assert_eq!(import_pict(b"").unwrap_err(), "not a PICT file"); + // An empty frame. + let mut b = vec![0, 0]; + b.extend(rect(10, 10, 10, 50)); + b.extend([0x11, 0x01, 0xFF]); + assert!(import_pict(&b).is_err()); +} + +#[test] +fn rounded_rectangles_ovals_arcs_and_same_shapes() { + let bytes = v2( + [0, 0, 100, 100], + &[ + (0x0B, be(&[20, 20])), + (0x41, rect(0, 0, 50, 50)), + (0x51, rect(50, 0, 100, 50)), + // A wedge from 12 to 3 o'clock. + (0x61, [rect(0, 50, 100, 150), be(&[0, 90])].concat()), + fg(0, 0xFFFF, 0), + // paintSameRect after paintRect: the same rectangle. + (0x31, rect(60, 60, 70, 80)), + (0x39, vec![]), + ], + ); + let p = import_pict(&bytes).unwrap(); + let v = paths(&p.document); + assert_eq!(v.len(), 5, "{v:?}"); + assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 50.0, 50.0), 1e-6)); + assert!(near(v[1].geometric_bounds().unwrap(), Rect::new(0.0, 50.0, 50.0, 100.0), 1e-3)); + assert!(near(v[2].geometric_bounds().unwrap(), Rect::new(100.0, 0.0, 150.0, 50.0), 1e-3), "{:?}", v[2].geometric_bounds()); + assert_eq!(v[3].geometric_bounds(), v[4].geometric_bounds()); + assert_eq!(fill_rgb(&v[4]), Some([0, 255, 0])); +} + +#[test] +fn polygons_and_regions_fill_and_clip() { + // An L: the top band 20 wide, the bottom band 10. + let region = [be(&[36]), rect(0, 0, 20, 20), be(&[0, 0, 20, 0x7FFF, 10, 10, 20, 0x7FFF, 20, 0, 10, 0x7FFF, 0x7FFF])].concat(); + let poly = [be(&[22]), rect(0, 0, 40, 40), be(&[0, 0, 40, 40, 0, 40])].concat(); + let bytes = v2([0, 0, 100, 100], &[(0x81, region.clone()), (0x71, poly), (0x01, region), (0x31, rect(0, 0, 100, 100))]); + let p = import_pict(&bytes).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + let v = paths(&p.document); + assert_eq!(v.len(), 3); + let NodeKind::Path { path, .. } = &v[0].kind else { panic!("a path") }; + assert_eq!(path.subpaths.len(), 2, "two bands"); + assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 20.0, 20.0), 1e-9)); + assert!(near(v[1].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 40.0, 40.0), 1e-9)); + // The rectangle after the clip region is clipped to it. + let layer = p.document.layers[0].children().unwrap(); + let NodeKind::Group { children, clip: true } = &layer[2].kind else { panic!("a clipping group: {:?}", layer[2].kind) }; + assert!(near(children[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 20.0, 20.0), 1e-9)); +} + +#[test] +fn patterns_fill_with_their_average_color() { + let gray = vec![0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55]; + let bytes = v2( + [0, 0, 10, 10], + &[(0x0A, gray), (0x34, rect(0, 0, 10, 10)), (0x12, [be(&[2]), vec![0; 8], be(&[0, -1, 0])].concat()), (0x32, rect(0, 0, 5, 5))], + ); + let p = import_pict(&bytes).unwrap(); + let v = paths(&p.document); + assert_eq!(fill_rgb(&v[0]), Some([128, 128, 128])); + // eraseRect with an RGB background pattern. + assert_eq!(fill_rgb(&v[1]), Some([0, 255, 0])); + assert!(p.warnings.iter().any(|w| w.contains("average of their colors")), "{:?}", p.warnings); +} + +#[test] +fn text_comes_in_as_point_type_in_the_named_font() { + let name = [be(&[9, 300]), vec![6], b"Geneva".to_vec()].concat(); + let bytes = v2( + [0, 0, 100, 200], + &[(0x2C, name), (0x03, be(&[300])), (0x0D, be(&[18])), (0x04, vec![1]), (0x28, [be(&[50, 10]), vec![4], b"Caf\x8E".to_vec()].concat())], + ); + let p = import_pict(&bytes).unwrap(); + let texts: Vec<_> = all(&p.document).into_iter().filter(|n| matches!(n.kind, NodeKind::Text(_))).collect(); + let NodeKind::Text(t) = &texts[0].kind else { panic!("text") }; + assert_eq!(t.plain_text(), "Café"); + let st = &t.runs[0].style; + assert_eq!((st.font_family.as_str(), st.font_style.as_str(), st.size), ("Geneva", "Bold", 18.0)); + // The text location is the baseline's start. + assert_eq!(t.xf.translation(), Point::new(10.0, 50.0).to_vec2()); +} + +/// A pixel map's fields after `rowBytes` and its bounds: version, packing, resolution, depth and +/// components. +fn pixmap_fields(pack: i16, size: i16, cmps: i16) -> Vec { + be(&[0, pack, 0, 0, 72, 0, 72, 0, 0, size, cmps, 8, 0, 0, 0, 0, 0, 0]) +} + +#[test] +fn a_packed_indexed_pixel_map_becomes_an_image() { + let table = be(&[0, 0, 0, 1, 0, -1, 0, 0, 1, 0, 0, -1]); + // An 8 × 2 map: a run of red, then blue and red by turns. + let rows = [vec![2, 0xF9, 0], vec![9, 7, 1, 0, 1, 0, 1, 0, 1, 0]].concat(); + let data = + [be(&[0x8008u16 as i16]), rect(0, 0, 2, 8), pixmap_fields(0, 8, 1), table, rect(0, 0, 2, 8), rect(10, 20, 14, 36), be(&[0]), rows].concat(); + let p = import_pict(&v2([0, 0, 50, 50], &[(0x98, data)])).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + let imgs = images(&p.document); + assert_eq!(imgs.len(), 1); + let (n, px) = &imgs[0]; + assert_eq!(px.dimensions(), (8, 2)); + assert_eq!(px.get_pixel(0, 0).0, [255, 0, 0, 255]); + assert_eq!(px.get_pixel(0, 1).0, [0, 0, 255, 255]); + assert_eq!(px.get_pixel(1, 1).0, [255, 0, 0, 255]); + // Drawn into its destination, twice its size. + assert!(near(n.geometric_bounds().unwrap(), Rect::new(20.0, 10.0, 36.0, 14.0), 1e-9)); +} + +#[test] +fn direct_pixel_maps_and_masked_bitmaps() { + // 32-bit, packed by component planes: red, then green. + let row = vec![7, 5, 255, 0, 0, 255, 0, 0]; + let direct = + [be(&[0, 0xFF, 0x8008u16 as i16]), rect(0, 0, 1, 2), pixmap_fields(4, 32, 3), rect(0, 0, 1, 2), rect(0, 0, 1, 2), be(&[0]), row].concat(); + // A one-bit bitmap ORed in (its clear bits left out), masked by a region. + let mask = [be(&[10]), rect(0, 0, 1, 1)].concat(); + let bitmap = [be(&[2]), rect(0, 0, 1, 2), rect(0, 0, 1, 2), rect(5, 0, 6, 2), be(&[1]), mask, vec![0x80, 0]].concat(); + let p = import_pict(&v2([0, 0, 10, 10], &[(0x9A, direct), (0x91, bitmap)])).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + let imgs = images(&p.document); + assert_eq!(imgs.len(), 2); + assert_eq!(imgs[0].1.get_pixel(0, 0).0, [255, 0, 0, 255]); + assert_eq!(imgs[0].1.get_pixel(1, 0).0, [0, 255, 0, 255]); + assert_eq!(imgs[1].1.get_pixel(0, 0).0, [0, 0, 0, 255]); + assert_eq!(imgs[1].1.get_pixel(1, 0).0[3], 0, "the clear bit is left out"); + let layer = p.document.layers[0].children().unwrap(); + assert!(matches!(layer[1].kind, NodeKind::Group { clip: true, .. }), "the mask region clips the bitmap"); +} + +/// A pixel map's rows may be only a little longer than its pixels need: a map one pixel wide +/// with rows of 16,383 bytes is left out, and a pixel pattern like it fills with its bitmap. +#[test] +fn rows_far_longer_than_their_pixels_are_refused() { + let table = be(&[0, 0, 0, 1, 0, -1, 0, 0, 1, -1, 0, 0]); + // A row of 16,383 ones: 127 runs of 128 and one of 127, after its byte count. + let mut row = be(&[256]); + (0..127).for_each(|_| row.extend([0x81, 1])); + row.extend([0x82, 1]); + let rows = row.repeat(4); + let map = [be(&[0xBFFFu16 as i16]), rect(0, 0, 4, 1), pixmap_fields(0, 8, 1), table.clone()].concat(); + let data = [map, rect(0, 0, 4, 1), rect(0, 0, 4, 1), be(&[0]), rows].concat(); + let p = import_pict(&v2([0, 0, 10, 10], &[(0x98, data)])).unwrap(); + assert!(images(&p.document).is_empty()); + assert!(p.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", p.warnings); + + let pattern = [be(&[1]), vec![0xFF; 8], be(&[0xBFFFu16 as i16]), rect(0, 0, 1, 1), pixmap_fields(0, 8, 1), table, row].concat(); + let p = import_pict(&v2([0, 0, 10, 10], &[(0x12, pattern), (0x32, rect(0, 0, 5, 5))])).unwrap(); + assert_eq!(fill_rgb(&paths(&p.document)[0]), Some([0, 0, 0]), "the pattern's bitmap in the foreground color"); +} + +/// BitsRect rows are stored unpacked, also when a row is 8 bytes or longer. +#[test] +fn bitmap_rows_of_eight_bytes_or_more_are_read_unpacked() { + let row = vec![0x80, 0, 0, 0, 0, 0, 0, 0]; + let bitmap = [be(&[8]), rect(0, 0, 1, 16), rect(0, 0, 1, 16), rect(0, 0, 1, 16), be(&[0]), row].concat(); + let p = import_pict(&v2([0, 0, 10, 20], &[(0x90, bitmap), (0x31, rect(2, 2, 4, 4))])).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + let imgs = images(&p.document); + assert_eq!(imgs.len(), 1); + assert_eq!(imgs[0].1.dimensions(), (16, 1)); + assert_eq!(imgs[0].1.get_pixel(0, 0).0, [0, 0, 0, 255]); + assert_eq!(imgs[0].1.get_pixel(1, 0).0, [255, 255, 255, 255]); + assert_eq!(paths(&p.document).len(), 1, "the rectangle after it"); +} + +/// An empty clip region clips away what follows it. +#[test] +fn an_empty_clip_region_hides_what_follows() { + let empty = [be(&[10]), rect(0, 0, 0, 0)].concat(); + let p = import_pict(&v2([0, 0, 50, 50], &[(0x01, empty), (0x31, rect(0, 0, 10, 10))])).unwrap(); + let layer = p.document.layers[0].children().unwrap(); + let NodeKind::Group { children, clip: true } = &layer[0].kind else { panic!("a clipping group: {:?}", layer[0].kind) }; + let b = children[0].geometric_bounds().unwrap(); + assert_eq!((b.width(), b.height()), (0.0, 0.0), "{b:?}"); +} + +/// A QuickTime image of `ctype` with `matrix` (a, b, c, d, tx, ty in points) and source rectangle +/// `w` × `h`, holding `data`. +fn quicktime(ctype: &[u8; 4], matrix: [f64; 6], w: i16, h: i16, data: &[u8]) -> Vec { + let fixed = |v: f64| ((v * 65536.0) as i32).to_be_bytes(); + let [a, b, c, d, tx, ty] = matrix; + let mut q = be(&[0]); + for v in [a, b, 0.0, c, d, 0.0, tx, ty] { + q.extend(fixed(v)); + } + q.extend((1i32 << 30).to_be_bytes()); + q.extend([0; 4]); + q.extend([0; 8]); + q.extend(be(&[0])); + q.extend(rect(0, 0, h, w)); + q.extend([0; 4]); + q.extend([0; 4]); + let mut desc = vec![]; + desc.extend(86u32.to_be_bytes()); + desc.extend(ctype); + desc.extend([0; 24]); + desc.extend(be(&[w, h])); + desc.extend([0; 8]); + desc.extend((data.len() as u32).to_be_bytes()); + desc.resize(86, 0); + q.extend(desc); + q.extend(data); + [(q.len() as u32).to_be_bytes().to_vec(), q].concat() +} + +fn red_jpeg() -> Vec { + let img = image::RgbImage::from_pixel(4, 2, image::Rgb([255, 0, 0])); + let mut out = std::io::Cursor::new(vec![]); + img.write_to(&mut out, image::ImageFormat::Jpeg).unwrap(); + out.into_inner() +} + +#[test] +fn quicktime_jpeg_images_are_placed_by_their_matrix() { + let jpeg = red_jpeg(); + let ops = [ + (0x8200, quicktime(b"jpeg", [2.0, 0.0, 0.0, 2.0, 10.0, 5.0], 4, 2, &jpeg)), + (0x8200, quicktime(b"rle ", [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], 4, 2, &[1, 2, 3])), + ]; + let p = import_pict(&v2([0, 0, 50, 50], &ops)).unwrap(); + let imgs = images(&p.document); + assert_eq!(imgs.len(), 1); + let (n, px) = &imgs[0]; + assert_eq!(px.dimensions(), (4, 2)); + let [r, g, b, _] = px.get_pixel(1, 1).0; + assert!(r > 240 && g < 16 && b < 16, "{r} {g} {b}"); + assert!(near(n.geometric_bounds().unwrap(), Rect::new(10.0, 5.0, 18.0, 9.0), 1e-9)); + assert!(p.warnings.iter().any(|w| w.contains("other than JPEG")), "{:?}", p.warnings); +} + +/// An image whose matrix flattens it is left out, and adds nothing to the document's images. +#[test] +fn a_flattened_image_adds_no_image_data() { + let p = import_pict(&v2([0, 0, 50, 50], &[(0x8200, quicktime(b"jpeg", [0.0; 6], 4, 2, &red_jpeg()))])).unwrap(); + assert!(images(&p.document).is_empty()); + assert!(p.document.images.is_empty(), "{:?}", p.document.images.keys().collect::>()); +} + +#[test] +fn pictures_cut_short_keep_what_came_before() { + let full = v2([0, 0, 50, 50], &[(0x31, rect(0, 0, 10, 10)), (0x31, rect(20, 20, 30, 30))]); + // Cut inside the second rectangle. + let cut = &full[..full.len() - 6]; + let p = import_pict(cut).unwrap(); + assert_eq!(paths(&p.document).len(), 1); + assert!(p.warnings.iter().any(|w| w.contains("cut short")), "{:?}", p.warnings); + // A long comment longer than the file. + let p = import_pict(&v2([0, 0, 50, 50], &[(0x31, rect(0, 0, 10, 10)), (0xA1, be(&[100, 5000]))])).unwrap(); + assert_eq!(paths(&p.document).len(), 1); + assert!(p.warnings.iter().any(|w| w.contains("cut short")), "{:?}", p.warnings); + // Nothing drawn. + let p = import_pict(&v2([0, 0, 50, 50], &[(0xA0, be(&[100]))])).unwrap(); + assert!(p.warnings.iter().any(|w| w.contains("draws nothing")), "{:?}", p.warnings); +} + +#[test] +fn reserved_opcodes_are_skipped_by_their_lengths() { + let ops = [ + // A 16-bit length, a fixed length for Apple's reserved words, and a 32-bit length. + (0x0024, be(&[4, 1, 2])), + (0x0200, be(&[1, 2])), + (0x00D0, [8u32.to_be_bytes().to_vec(), vec![9; 8]].concat()), + (0x8201, [2u32.to_be_bytes().to_vec(), vec![9; 2]].concat()), + (0x31, rect(0, 0, 10, 10)), + ]; + let p = import_pict(&v2([0, 0, 50, 50], &ops)).unwrap(); + assert_eq!(paths(&p.document).len(), 1, "{:?}", p.warnings); +} + +/// A picture of nothing but no-ops stops at the opcode limit. +#[test] +fn a_picture_that_never_ends_stops_at_the_opcode_limit() { + let mut b = v2([0, 0, 50, 50], &[(0x31, rect(0, 0, 10, 10))]); + b.truncate(b.len() - 2); + b.resize(b.len() + 2 * ((1 << 22) + 1), 0); + let p = import_pict(&b).unwrap(); + assert_eq!(paths(&p.document).len(), 1); + assert!(p.warnings.iter().any(|w| w.contains("more opcodes")), "{:?}", p.warnings); +} + +/// Regions share one budget of inversion points per picture: past it, a region is left out. +#[test] +fn regions_past_the_picture_budget_are_left_out() { + // One scanline of 16,000 inversion points: 8,000 rectangles. + let mut words = vec![0]; + words.extend(0..16_000i16); + words.extend([0x7FFF, 1, 0x7FFF, 0x7FFF]); + let body = [rect(0, 0, 1, 16_000), be(&words)].concat(); + let region = [be(&[(body.len() + 2) as i16]), body].concat(); + let p = import_pict(&v2([0, 0, 300, 300], &[(0x81, region.clone()), (0x81, region.clone()), (0x81, region)])).unwrap(); + assert!(p.warnings.iter().any(|w| w.contains("regions larger")), "{:?}", p.warnings); + let v = paths(&p.document); + assert_eq!(v.len(), 2); + let NodeKind::Path { path, .. } = &v[0].kind else { panic!("a path") }; + assert_eq!(path.subpaths.len(), 8_000); +} diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 33df72c253..13b24da639 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5812,6 +5812,7 @@ digits Malayalam Malaiàlam @msg not a VectorCraft shortcut set (missing `overrides`) no és un conjunt de dreceres de VectorCraft (falta `overrides`) @msg not an EMF or WMF file no és un fitxer EMF o WMF @msg not an HP-GL file no és un fitxer HP-GL +@msg not a PICT file no és un fitxer PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` ha de ser una matriu d’1 a 255 colors #rrggbb @msg not an RTF file: it doesn't start with {\\rtf no és un fitxer RTF: no comença amb {\\rtf @msg not found no s’ha trobat diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ff48f1ecfc..472c387521 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5012,6 +5012,7 @@ imageTrace Outline Obrys @msg not a valid plug-in module: {0} neplatný modul zásuvného modulu: {0} @msg not an EMF or WMF file nejde o soubor EMF ani WMF @msg not an HP-GL file nejde o soubor HP-GL +@msg not a PICT file nejde o soubor PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` musí být pole 1 až 255 barev #rrggbb @msg not found nenalezeno @msg not in pattern editing mode není aktivní režim úprav vzorku diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1d3b12f63a..5080e87138 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4629,6 +4629,7 @@ theme Appearance Erscheinungsbild @msg not a valid plug-in module: {0} kein gültiges Plug-in-Modul: {0} @msg not an EMF or WMF file keine EMF- oder WMF-Datei @msg not an HP-GL file keine HP-GL-Datei +@msg not a PICT file keine PICT-Datei @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` muss ein Array von 1 bis 255 Farben der Form #rrggbb sein @msg not found nicht gefunden @msg not in pattern editing mode nicht im Musterbearbeitungsmodus diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8467b8e831..5550f9e15f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4657,6 +4657,7 @@ digits Malayalam Μαλαγιάλαμ @msg not a valid plug-in module: {0} δεν είναι έγκυρη μονάδα προσθέτου: {0} @msg not an EMF or WMF file δεν είναι αρχείο EMF ή WMF @msg not an HP-GL file δεν είναι αρχείο HP-GL +@msg not a PICT file δεν είναι αρχείο PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors το `pens` πρέπει να είναι πίνακας 1 έως 255 χρωμάτων #rrggbb @msg not found δεν βρέθηκε @msg not in pattern editing mode δεν βρίσκεστε σε λειτουργία επεξεργασίας μοτίβου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 194ba2c602..d750ab8d0d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4704,6 +4704,7 @@ axis Both Ambos @msg not a valid plug-in module: {0} no es un módulo de plugin válido: {0} @msg not an EMF or WMF file no es un archivo EMF ni WMF @msg not an HP-GL file no es un archivo HP-GL +@msg not a PICT file no es un archivo PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` debe ser una lista de 1 a 255 colores #rrggbb @msg not found no se encontró @msg not in pattern editing mode no está en el modo de edición de motivos diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e36c5e891e..b366a462b1 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4918,6 +4918,7 @@ imageTrace Outline Ääriviiva @msg not a valid plug-in module: {0} ei kelvollinen laajennusmoduuli: {0} @msg not an EMF or WMF file ei EMF- tai WMF-tiedosto @msg not an HP-GL file ei HP-GL-tiedosto +@msg not a PICT file ei PICT-tiedosto @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` on oltava 1–255 #rrggbb-värin taulukko @msg not found ei löytynyt @msg not in pattern editing mode ei kuvion muokkaustilassa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 067845f493..9b2bb5569a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4693,6 +4693,7 @@ axis Both Les deux @msg not a valid plug-in module: {0} ce n’est pas un module de plug-in valide : {0} @msg not an EMF or WMF file ce n’est pas un fichier EMF ou WMF @msg not an HP-GL file ce n’est pas un fichier HP-GL +@msg not a PICT file ce n’est pas un fichier PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` doit être une liste de 1 à 255 couleurs #rrggbb @msg not found introuvable @msg not in pattern editing mode pas en mode d’édition de motif diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 165e04ae7d..f06be2e82a 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7369,6 +7369,7 @@ antiAlias Sharp शार्प @msg not an action set file एक एक्शन सेट फ़ाइल नहीं @msg not an EMF or WMF file नहीं एक EMF या WMF फ़ाइल @msg not an HP-GL file HP-GL फ़ाइल नहीं है +@msg not a PICT file PICT फ़ाइल नहीं है @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` को 1 से 255 #rrggbb रंगों की ऐरे होना चाहिए @msg not an export presets file (format `{_1}`) नहीं निर्यात presets फ़ाइल (format `{_1}`) @msg not an export presets file: {e} एक निर्यात प्रीसेट फ़ाइल नहीं: {e} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2ccf9d57bc..c26bf6420b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5389,6 +5389,7 @@ imageTrace Outline Körvonal @msg not a blend nem összemosás @msg not an EMF or WMF file nem EMF- vagy WMF-fájl @msg not an HP-GL file nem HP-GL-fájl +@msg not a PICT file nem PICT-fájl @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens`: 1–255 #rrggbb szín tömbje kell @msg not found nem található @msg not in pattern editing mode nem mintázatszerkesztő módban vagy diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index be83d78b43..0c97004be1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4755,6 +4755,7 @@ axis Both Entrambi @msg not a valid plug-in module: {0} non è un modulo plug-in valido: {0} @msg not an EMF or WMF file non è un file EMF o WMF @msg not an HP-GL file non è un file HP-GL +@msg not a PICT file non è un file PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` deve essere un elenco da 1 a 255 colori #rrggbb @msg not found non trovato @msg not in pattern editing mode non sei in modalità modifica motivo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b27c6d2698..98a8a7c8ac 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4744,6 +4744,7 @@ swatch Bright Violet ビビッドバイオレット @msg {bpp}-bit bitmaps can't be read {bpp} ビットのビットマップは読み込めません @msg not an EMF or WMF file EMF ファイルでも WMF ファイルでもありません @msg not an HP-GL file HP-GL ファイルではありません +@msg not a PICT file PICT ファイルではありません @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` は 1〜255 個の #rrggbb 形式のカラーの配列にしてください @msg its layers use {_1}, which aren't read yet レイヤーで使用されている {_1} はまだ読み込めません @msg the EMF header is damaged EMF ヘッダーが破損しています diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 4ba40baab0..7cdb2dfb86 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4382,6 +4382,7 @@ font-technology Variable ცვლადი @msg not a valid plug-in module: {0} ეს არ არის პლაგინის სწორი მოდული: {0} @msg not an EMF or WMF file ეს არ არის EMF ან WMF ფაილი @msg not an HP-GL file ეს არ არის HP-GL ფაილი +@msg not a PICT file ეს არ არის PICT ფაილი @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` უნდა იყოს 1-დან 255-მდე #rrggbb ფერის მასივი @msg not found ვერ მოიძებნა @msg not in pattern editing mode პატერნის რედაქტირების რეჟიმი არ არის ჩართული diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c46ce6b4fa..c886f8c035 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4671,6 +4671,7 @@ theme Appearance 외관 @msg {bpp}-bit bitmaps can't be read {bpp}비트 비트맵은 읽을 수 없습니다 @msg not an EMF or WMF file EMF 또는 WMF 파일이 아닙니다 @msg not an HP-GL file HP-GL 파일이 아닙니다 +@msg not a PICT file PICT 파일이 아닙니다 @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens`는 #rrggbb 색상 1~255개의 배열이어야 합니다 @msg its layers use {_1}, which aren't read yet 레이어에 {_1}이(가) 있으며, 아직 읽을 수 없습니다 @msg the EMF header is damaged EMF 헤더가 손상되었습니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a232ac4e8d..793eb8e15b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4837,6 +4837,7 @@ imageTrace Outline Kontur @msg not a valid plug-in module: {0} nieprawidłowy moduł wtyczki: {0} @msg not an EMF or WMF file to nie jest plik EMF ani WMF @msg not an HP-GL file to nie jest plik HP-GL +@msg not a PICT file to nie jest plik PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` musi być tablicą od 1 do 255 kolorów #rrggbb @msg not found nie znaleziono @msg not in pattern editing mode nie jest w trybie edycji wzorka diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 68a225625d..2fd53cf23e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4161,6 +4161,7 @@ axis Both Ambos @msg not a valid plug-in module: {0} não é um módulo de plug-in válido: {0} @msg not an EMF or WMF file não é um arquivo EMF ou WMF @msg not an HP-GL file não é um arquivo HP-GL +@msg not a PICT file não é um arquivo PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` deve ser uma matriz de 1 a 255 cores #rrggbb @msg not found não encontrado @msg not in pattern editing mode não está no modo de edição de padrão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a54d1eed2c..6ca7f542eb 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4741,6 +4741,7 @@ axis Both Оба @msg not a valid plug-in module: {0} не является допустимым модулем: {0} @msg not an EMF or WMF file не является файлом EMF или WMF @msg not an HP-GL file не является файлом HP-GL +@msg not a PICT file не является файлом PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` должен быть массивом из 1–255 цветов #rrggbb @msg not found не найдено @msg not in pattern editing mode не в режиме редактирования узора diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index d0648b5946..e5d23af1e8 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4605,6 +4605,7 @@ axis Both Båda @msg not a valid plug-in module: {0} inte ett giltigt plugin: {0} @msg not an EMF or WMF file inte en EMF- eller WMF-fil @msg not an HP-GL file inte en HP-GL-fil +@msg not a PICT file inte en PICT-fil @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` måste vara en array med 1 till 255 färger i formatet #rrggbb @msg not found hittades inte @msg not in pattern editing mode inte i läget för mönsterredigering diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 915a244b43..3b94838c6b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4460,6 +4460,7 @@ axis Both Her İkisi @msg not a valid plug-in module: {0} geçerli bir eklenti modülü değil: {0} @msg not an EMF or WMF file EMF veya WMF dosyası değil @msg not an HP-GL file HP-GL dosyası değil +@msg not a PICT file PICT dosyası değil @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 1 ile 255 arasında #rrggbb renginden oluşan bir dizi olmalıdır @msg not an RTF file: it doesn't start with {\\rtf bir RTF dosyası değil: {\\rtf ile başlamıyor @msg not found bulunamadı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e8b5445a50..43996c53e1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4739,6 +4739,7 @@ axis Both Обидві @msg not a valid plug-in module: {0} не є допустимим модулем: {0} @msg not an EMF or WMF file не є файлом EMF або WMF @msg not an HP-GL file не є файлом HP-GL +@msg not a PICT file не є файлом PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` має бути масивом із 1–255 кольорів #rrggbb @msg not found не знайдено @msg not in pattern editing mode не в режимі редагування візерунка diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 51681bfed5..16147c95d8 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5052,6 +5052,7 @@ theme Appearance Giao diện @msg This shortcut file is in a form VectorCraft can't read. Tệp phím tắt này có dạng mà VectorCraft không đọc được. @msg not an EMF or WMF file không phải tệp EMF hoặc WMF @msg not an HP-GL file không phải tệp HP-GL +@msg not a PICT file không phải tệp PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` phải là mảng từ 1 đến 255 màu #rrggbb @msg not found không tìm thấy @msg not in pattern editing mode không ở chế độ chỉnh sửa họa tiết diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d1e4de6bc6..2246fc7b7f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4442,6 +4442,7 @@ imageTrace Outline 轮廓 @msg {bpp}-bit bitmaps can't be read 无法读取 {bpp} 位位图 @msg not an EMF or WMF file 不是 EMF 或 WMF 文件 @msg not an HP-GL file 不是 HP-GL 文件 +@msg not a PICT file 不是 PICT 文件 @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 必须是由 1 到 255 个 #rrggbb 格式颜色组成的数组 @msg its layers use {_1}, which aren't read yet 其图层使用了 {_1},尚不能读取 @msg the EMF header is damaged EMF 文件头已损坏 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8e49ed109a..e1315f27e9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2212,6 +2212,7 @@ file-filter Templates 範本 @msg {bpp}-bit bitmaps can't be read 無法讀取 {bpp} 位元點陣圖 @msg not an EMF or WMF file 不是 EMF 或 WMF 檔案 @msg not an HP-GL file 不是 HP-GL 檔案 +@msg not a PICT file 不是 PICT 檔案 @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 必須是由 1 到 255 個 #rrggbb 格式顏色組成的陣列 @msg its layers use {_1}, which aren't read yet 其圖層使用了 {_1},目前尚無法讀取 @msg the EMF header is damaged EMF 檔頭已損毀 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 465f1701a8..e0964f7200 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -21,7 +21,7 @@ Ordinary File > Save offers Save As when its own location refuses writes with a | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| -| Formats opened or placed | ~30 | 30 | missing: FreeHand, CorelDRAW, DWG, PICT, PS | +| Formats opened or placed | ~30 | 31 | missing: FreeHand, CorelDRAW, DWG, PS | | Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, gradients, patterns, images, type, symbols, artboards, transparency, opacity masks, clipping) or as legacy version 7 (layers and groups, one artboard per file) | | Formats exported | ~16 | 18 | missing: DWG; extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | | **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting | @@ -284,7 +284,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | HEIC / HEIF | open, place | yes (HEVC-coded still images: single pictures and grids of tiles, 8 to 10 bits, with an alpha image; image sequences, other codings and grids of cropped tiles are refused) | no | `.heic`, `.heif`; decoded by heic-rs in the engine's default `heif` feature (a build without it refuses the files with a message); the container's rotation, mirror and crop are applied; without an `nclx` box the HEVC stream's video signal type gives the matrix and range; an RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; EXIF resolution tags size the image, or 72 ppi; bounded decoding; `tests_heifread.rs` | | PCX | open, place | yes (1 bit in the header palette's first two colors, black and white without them; 1 bit in two to four planes and 4 bits with up to 16 colors; 2 bits in the CGA colors the header's CGA palette settings pick; 8 bits with the 256-color palette, gray without one; 24-bit color in three planes) | no | the header's HDpi and VDpi size the image; a file of another depth or plane count, cut short or past the pixel allowance is refused with a message; tests in `crates/engine/src/cmd/fileio/tests_pcxread.rs` and `crates/engine/tests/import_fuzz.rs` | | JPEG 2000 | open, place | yes (JP2 and raw codestream images; multiple codestreams and JPX composition or layers are refused) | no | `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c`, `.jpc`; resolution uses the valid `resd` (display) value on each axis, or else the valid `resc` (capture) value, or 72 ppi; bounded decoding; `tests_jp2read.rs` | -| PICT | open, place (legacy) | no | no | rarely used | +| PICT | open, place (legacy) | yes (versions 1 and 2: shapes, lines, regions, text, bitmaps, pixel maps and QuickTime JPEG images; placed as one group clipped to the picture's frame) | no | `.pct`, `.pic` and `.pict`; a version 2 picture also opens by its content; a two-color pattern fills with its average color; inverted shapes, transfer modes other than a copy and QuickTime images in other formats come back as warnings; a picture cut short keeps what came before it; tests in `crates/metafile/src/tests/pict.rs`, `crates/engine/src/cmd/fileio/tests_pict.rs` and `crates/engine/tests/import_fuzz.rs` | | Pixar PXR | open, place (legacy) | yes (any set of the R, G, B and A channels in 8 or 12 bits, dumped or run-length encoded, in tiles; R alone is gray; alpha matted to black or unassociated) | no | recognized by its magic number; 12-bit samples are clamped to 0.0 to 1.0; null tiles are transparent with A and black without; the offsets aren't read, and the image takes the default resolution; a file cut short, with header values or packets the format doesn't define, or past the pixel allowance is refused with a message; tested on synthetic files only; tests in `crates/engine/src/cmd/fileio/tests_pxrread.rs` and `crates/engine/tests/import_fuzz.rs` | | FreeHand `.fh*` | open (legacy) | no | no | | | CorelDRAW `.cdr` | open | no | no | | diff --git a/docs/mcp.md b/docs/mcp.md index 2c8afc7aea..926b457c61 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -629,7 +629,7 @@ sampler. | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. UI commands such as `view.*` and `window.*` are in `command_list` with `ui: true`; running them needs the desktop app (`--connect`). | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | -| `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, HP-GL (`.plt`/`.hpgl`/`.hgl`/`.hpg`, and a file of an extension no other format reads that starts with plotter instructions), PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a PSD or PSB file with its layers as layers, or as its merged image through `run_command document.open` with `layers: false`; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | +| `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PICT (`.pct`/`.pic`/`.pict`, and a version 2 picture of any extension), HP-GL (`.plt`/`.hpgl`/`.hgl`/`.hpg`, and a file of an extension no other format reads that starts with plotter instructions), PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a PSD or PSB file with its layers as layers, or as its merged image through `run_command document.open` with `layers: false`; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable; `.eps`: by default one file whose bounding box fits the visible art, the first artboard when no art shows; `options.useArtboards: true` makes the chosen artboard the page and, with two or more chosen, the file at the path is the central file of the visible art and each artboard is also written to `_.eps` beside it, listed in the reply's `files`). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. Location refusals return `READ_ONLY_LOCATION: ` without a fallback panel for automation; see [Save location errors](#save-location-errors). | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `hpgl` (a `.plt` plot file for plotters and cutters), `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF, WMF and HP-GL write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | @@ -6617,8 +6617,8 @@ no linked file, and `warnings` carries "an image couldn't be decoded and is miss images. DWG can't be written (no openly licensed writer exists): `document.formats` lists it under `unsupported` with that -hint, and exporting to it answers with the hint to export DXF. PICT files are not supported either and say so when -opened. +hint, and exporting to it answers with the hint to export DXF. PICT files open and place (see PICT import) but can't +be written. ```json {"name":"run_command","arguments":{"command":"document.exportDxf","params":{"path":"/tmp/plan.dxf","version":"2013","unit":"mm","scale":10,"preserve":"editability"}}} @@ -7020,6 +7020,36 @@ clipboard service offers it (the Windows desktop service doesn't read metafiles {"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/chart.emf","at":[300,200]}}} ``` +## PICT import + +`document.open` and `file.place` read QuickDraw pictures, versions 1 and 2 (`.pct`, `.pic`, and `.pict`, which is +VectorCraft's own name for them), into one artboard, the picture's frame in points, and one layer. A file holds 512 bytes +of application data before the picture; a picture without them reads too. A version 2 picture also opens by its content +under any name. Opcodes and their data lengths follow Apple's +[Opcodes in Pictures](https://dev.os9.ca/techpubs/mac/QuickDraw/QuickDraw-461.html) and +[Version and Header Opcodes](https://dev.os9.ca/techpubs/mac/QuickDraw/QuickDraw-459.html): + +- Lines, rectangles, rounded rectangles, ovals, arcs and wedges, polygons and regions become paths, in the order the + picture draws them. A framed shape is stroked inside its outline at the pen's size; a line is stroked below and to + the right of its points. Painted, filled and erased shapes are filled with the pen, fill or background pattern, and a + two-color pattern with the average of its colors. +- Text becomes point type in the font the picture names, at its size and style, read as Mac Roman. +- Bitmaps and pixel maps (1 to 8 bits with their color table, 16 and 32 bits, packed or not) become images, clipped to + their mask region. In the OR transfer mode, a bitmap's clear bits are left transparent. A QuickTime JPEG image + (opcode `0x8200`) becomes an image placed by its matrix. +- The clip region clips what follows it. + +`file.place` places the picture as one group clipped to its frame. What isn't read comes back in `warnings`, once each: +inverted shapes, transfer modes other than a copy (drawn as a copy), QuickTime images in formats other than JPEG, and +regions or images past the reader's limits. A picture cut short keeps what came before it with a warning, and one that +draws nothing VectorCraft reads opens empty with a warning. A file that holds no picture is refused with +"not a PICT file". PICT can't be written: `document.export` to it is refused with "PICT files can be opened but not +written". + +```json +{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/logo.pct","at":[300,200]}}} +``` + ## HP-GL import `document.open` and `file.place` read HP-GL/2 plot files (`.plt`, `.hpgl`, `.hgl`, `.hpg`) into one layer, with the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1ce5c5fda5..6ef5362460 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1583,6 +1583,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. Each axis uses the valid display resolution (`resd`), else the valid capture resolution (`resc`), whichever box comes first ([ITU-T T.800](https://www.itu.int/rec/T-REC-T.800/en), I.5.3.7.2); an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`). - HEIC and HEIF images open and place from `.heic` and `.heif`, as the [supported file formats](https://helpx.adobe.com/illustrator/kb/supported-file-formats-illustrator.html) list High Efficiency Image under both Open and Place (and [Fix HEIF file import errors on Windows](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/fix-heif-file-import-errors-on-windows.html) covers both ways of bringing HEIF files in): HEVC-coded single pictures and grids of tiles, 8 to 10 bits, with an alpha image, decoded by heic-rs in the engine's default `heif` feature. The container's rotation, mirror and crop are applied. Without an `nclx` box, the HEVC stream's video signal type gives the samples' matrix and range. An RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; the EXIF resolution tags size the image, or 72 ppi. Image sequences, other codings and grids of cropped tiles are refused with a message, and so is every HEIF file in a build without the feature. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_heifread.rs`). - HP-GL/2 plot files (`.plt`, `.hpgl`, `.hgl`, `.hpg`, or a name no other format reads when the file starts with plotter instructions) open and place as paths on one layer, the artboard round the art: each line drawn with the pen down is a stroked path in its pen's color and width, line types become dash patterns, circles, arcs, wedges and Bézier curves are curves, filled and edged rectangles, wedges and polygons (polygon mode) are filled or stroked paths, and encoded polylines, scaling points, user units and rotation are read. Labels, symbol mode's symbols, raster images and hatching aren't drawn and are listed in the warnings (`crates/cad/src/hpgl/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). + - PICT pictures (`.pct`, `.pic`, and `.pict` as VectorCraft's own name) open and place, as [the help page](https://helpx.adobe.com/archive/illustrator/illustrator-cs5-5-troubleshooting.pdf) lists Macintosh PICT under both opening and placing: versions 1 and 2, with or without the file's 512 bytes of application data, read by the opcodes of Apple's [Opcodes in Pictures](https://dev.os9.ca/techpubs/mac/QuickDraw/QuickDraw-461.html). Lines, rectangles, rounded rectangles, ovals, arcs, polygons and regions become paths in their pen and pattern colors, text becomes point type in the font the picture names, bitmaps, pixel maps and QuickTime JPEG images become images, and the clip region clips what follows it; File › Place places the picture as one group clipped to its frame. Inverted shapes, other transfer modes and other QuickTime formats come back as warnings, and a picture cut short keeps what came before it (`crates/metafile/src/tests/pict.rs`, `crates/engine/src/cmd/fileio/tests_pict.rs`). - Export As › HP-GL (`document.export {format: "hpgl"}`) writes a `.plt` plot of an artboard, each artboard or the art for pen plotters and cutters: each visible path once along its line in plotter units from the artboard's bottom-left corner or center, a pen per color or a pen table, curves flattened to a tolerance, type as outlines, clipping masks and the artboard's edges applied, and HP-GL or HP-GL/2 (pen colors and encoded polylines); plots read back by the HP-GL reader give the same lines (`crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). - HP-GL export's `insideFirst` cuts each pen's paths inside before outside: a path is drawn only after the pen's paths that lie inside it (a letter's counter, a part's holes, rings within rings), touching outlines counted as inside and crossing ones not, in paint order or nearest first with `sortPaths`, within the plot's work budget; these order rules are VectorCraft's own (`crates/cad/src/hpgl/write/nest.rs`, `crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). From b89ddeccd0a121442030ce9642ee52e5d81eba15 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 19:59:19 -0800 Subject: [PATCH 21/68] 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 c84ece827cfd40942c9b7ab21dc9c7b1c342d6ec Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 20:00:10 -0800 Subject: [PATCH 22/68] i18n: parameter schema errors translate what the value must be A parameter schema's error ("`zoom` must be a number > 0, not -1") was translated around its requirement, which stayed in English in every language: only "a number", "a string" and "true or false" had rows. - Every complete catalog gets a row for each requirement a schema writes: a number with each kind of range and with a unit, an integer and its range, a list of names, an object id, a list of object ids, a color, a point, an object and a list. The bounds, unit and names stay as the English gives them. - An enum error no longer matches the `{key}` must be one of {_1} row, which took `"a", "b", not "c"` as its list and left ", not ..." in English: a `{path}` must be one of {_1}, not {got} row translates it, in the complete catalogs and in Bulgarian, Bosnian, Danish, Indonesian and Dutch. - A part of a message that starts with `{` or `[` is translated only when it is an exact message, so "[x, y] (two numbers)" translates and JSON values, shown cut short or whole, stay as they are. Docs: docs/mcp.md (Parameter schemas), docs/target-app-parity.md. --- Cargo.lock | 1 + crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/i18n.rs | 16 ++- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 29 ++++++ crates/ui-egui/src/i18n/catalog.rs | 6 ++ crates/ui-egui/src/i18n/cs.tsv | 29 ++++++ crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 29 ++++++ crates/ui-egui/src/i18n/el.tsv | 29 ++++++ crates/ui-egui/src/i18n/es.tsv | 29 ++++++ crates/ui-egui/src/i18n/fi.tsv | 29 ++++++ crates/ui-egui/src/i18n/fr.tsv | 29 ++++++ crates/ui-egui/src/i18n/hi.tsv | 29 ++++++ crates/ui-egui/src/i18n/hu.tsv | 29 ++++++ crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 29 ++++++ crates/ui-egui/src/i18n/ja.tsv | 29 ++++++ crates/ui-egui/src/i18n/ka.tsv | 29 ++++++ crates/ui-egui/src/i18n/ko.tsv | 29 ++++++ crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 29 ++++++ crates/ui-egui/src/i18n/pt-br.tsv | 29 ++++++ crates/ui-egui/src/i18n/ru.tsv | 29 ++++++ crates/ui-egui/src/i18n/sv.tsv | 29 ++++++ crates/ui-egui/src/i18n/tests.rs | 154 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/tr.tsv | 29 ++++++ crates/ui-egui/src/i18n/uk.tsv | 29 ++++++ crates/ui-egui/src/i18n/vi.tsv | 29 ++++++ crates/ui-egui/src/i18n/zh-hans.tsv | 29 ++++++ crates/ui-egui/src/i18n/zh-hant.tsv | 29 ++++++ docs/mcp.md | 5 + docs/target-app-parity.md | 1 + 34 files changed, 823 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b42037f540..7e85c13b5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6854,6 +6854,7 @@ dependencies = [ "serde", "serde_json", "toml", + "vectorcraft-apischema", "vectorcraft-brush", "vectorcraft-cad", "vectorcraft-color", diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 8e30081d7f..2ea7ac8079 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -68,6 +68,7 @@ rustix = { workspace = true, features = ["std", "process"] } [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } +vectorcraft-apischema = { workspace = true } vectorcraft-testkit = { workspace = true } # A variable font built in code (no font file is committed). vectorcraft-text = { workspace = true, features = ["test-fonts"] } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 7ee09ee14e..e6f3cde533 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": [...]}`), stay as they are. A part that starts with `{` or `[` is translated only +/// when it is a whole message with an exact entry, such as `[x, y] (two numbers)`. pub fn message(lang: Lang, s: &str) -> String { message_at(lang, s, MESSAGE_DEPTH) } @@ -669,9 +670,16 @@ 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 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 nested = |v: &str| { + if depth <= 1 { + v.to_string() + } else if v.starts_with(['{', '[']) { + lang.catalog().exact_message(v).unwrap_or(v).to_string() + } else { + message_at(lang, v, depth - 1) + } + }; + let values: Vec<(&str, String)> = caps.into_iter().map(|(k, v)| (k, nested(v))).collect(); let args: Vec<(&str, &str)> = values.iter().map(|(k, v)| (*k, v.as_str())).collect(); fill_once(template, &args) } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ca25bdf078..562c881809 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4183,6 +4183,7 @@ axis Both И двете @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` трябва да бъде цяло число между {min} и {max} (получено е {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` трябва да бъде между {min} и {max} (получено е {x}) @msg `{key}` must be one of {_1} `{key}` трябва да бъде едно от {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` трябва да бъде едно от {_1}, а не {got} @msg `{key}` must be true or false `{key}` трябва да бъде true или false @msg `{k}` must be [x, y] `{k}` трябва да бъде [x, y] @msg `{name}` has no options `{name}` няма опции diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 69fcb871b9..c8a06e4d91 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4553,6 +4553,7 @@ imageTrace Outline Obris @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` mora biti cijeli broj između {min} i {max} (dobiveno {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` mora biti između {min} i {max} (dobiveno {x}) @msg `{key}` must be one of {_1} `{key}` mora biti jedno od {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` mora biti jedno od {_1}, a ne {got} @msg `{key}` must be true or false `{key}` mora biti true ili false @msg `top` and `below` can't both be true `top` i `below` ne mogu oba biti true @msg `select` must be true or false `select` mora biti true ili false diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 33df72c253..0dda77c19d 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6401,6 +6401,35 @@ digits Malayalam Malaiàlam @msg the path has no anchor points el traçat no té punts d’ancoratge @msg too many anchor points (limit {MAX_ANCHORS}) massa punts d’ancoratge (límit {MAX_ANCHORS}) @msg true or false true o false +@msg a number >= {_1} un nombre >= {_1} +@msg a number > {_1} un nombre > {_1} +@msg a number <= {_1} un nombre <= {_1} +@msg a number < {_1} un nombre < {_1} +@msg a number {_1} to {_2} un nombre de {_1} a {_2} +@msg a number >= {_1} and < {_2} un nombre >= {_1} i < {_2} +@msg a number > {_1} and <= {_2} un nombre > {_1} i <= {_2} +@msg a number > {_1} and < {_2} un nombre > {_1} i < {_2} +@msg a number ({_1}) un nombre ({_1}) +@msg a number >= {_1} ({_2}) un nombre >= {_1} ({_2}) +@msg a number > {_1} ({_2}) un nombre > {_1} ({_2}) +@msg a number <= {_1} ({_2}) un nombre <= {_1} ({_2}) +@msg a number < {_1} ({_2}) un nombre < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) un nombre de {_1} a {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) un nombre >= {_1} i < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) un nombre > {_1} i <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) un nombre > {_1} i < {_2} ({_3}) +@msg an integer un enter +@msg an integer >= {_1} un enter >= {_1} +@msg an integer <= {_1} un enter <= {_1} +@msg an integer {_1} to {_2} un enter de {_1} a {_2} +@msg one of {_1} un de {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` ha de ser un de {_1}, no {got} +@msg an object id (a non-negative integer) un identificador d’objecte (un enter no negatiu) +@msg a list of object ids una llista d’identificadors d’objecte +@msg a color (a string, [r, g, b] or an object) un color (una cadena, [r, g, b] o un objecte) +@msg [x, y] (two numbers) [x, y] (dos nombres) +@msg an object un objecte +@msg a list una llista @msg unknown key `{k}`; the keys here are {_1} clau desconeguda `{k}`; les claus aquí són {_1} @msg {_1} is listed twice {_1} apareix dues vegades @msg {_1} is not the id of an input object {_1} no és l’id d’un objecte d’entrada diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs index 138299643d..1f450a20be 100644 --- a/crates/ui-egui/src/i18n/catalog.rs +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -230,6 +230,12 @@ impl Catalog { self.templates.iter().find_map(|t| t.captures(s).map(|caps| (t.forms.as_slice(), caps))) } + /// The translation of a status or error message that has an exact `@msg` entry; templates are + /// not tried. + pub fn exact_message(&self, s: &str) -> Option<&str> { + self.messages.get(s).map(String::as_str) + } + /// The plural form `index` of the message whose English forms are `one|other`. pub fn plural(&self, one: &str, other: &str, index: usize) -> Option<&str> { let forms = self.plurals.get(&format!("{one}|{other}"))?; diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ff48f1ecfc..40e12bf6f4 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6323,6 +6323,35 @@ axis Y Axis osa Y @msg the path has no anchor points cesta nemá žádné kotevní body @msg too many anchor points (limit {MAX_ANCHORS}) příliš mnoho kotevních bodů (limit {MAX_ANCHORS}) @msg true or false true nebo false +@msg a number >= {_1} číslo >= {_1} +@msg a number > {_1} číslo > {_1} +@msg a number <= {_1} číslo <= {_1} +@msg a number < {_1} číslo < {_1} +@msg a number {_1} to {_2} číslo od {_1} do {_2} +@msg a number >= {_1} and < {_2} číslo >= {_1} a < {_2} +@msg a number > {_1} and <= {_2} číslo > {_1} a <= {_2} +@msg a number > {_1} and < {_2} číslo > {_1} a < {_2} +@msg a number ({_1}) číslo ({_1}) +@msg a number >= {_1} ({_2}) číslo >= {_1} ({_2}) +@msg a number > {_1} ({_2}) číslo > {_1} ({_2}) +@msg a number <= {_1} ({_2}) číslo <= {_1} ({_2}) +@msg a number < {_1} ({_2}) číslo < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) číslo od {_1} do {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) číslo >= {_1} a < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) číslo > {_1} a <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) číslo > {_1} a < {_2} ({_3}) +@msg an integer celé číslo +@msg an integer >= {_1} celé číslo >= {_1} +@msg an integer <= {_1} celé číslo <= {_1} +@msg an integer {_1} to {_2} celé číslo od {_1} do {_2} +@msg one of {_1} jedna z hodnot {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` musí být jedna z hodnot {_1}, nikoli {got} +@msg an object id (a non-negative integer) ID objektu (nezáporné celé číslo) +@msg a list of object ids seznam ID objektů +@msg a color (a string, [r, g, b] or an object) barva (řetězec, [r, g, b] nebo objekt) +@msg [x, y] (two numbers) [x, y] (dvě čísla) +@msg an object objekt +@msg a list seznam @msg unknown key `{k}`; the keys here are {_1} neznámý klíč `{k}`; zde platné klíče jsou {_1} @msg {_1} is listed twice {_1} je uveden dvakrát @msg {_1} is not the id of an input object {_1} není ID vstupního objektu diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 38300ca372..6b99525b1c 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4299,6 +4299,7 @@ imageTrace Outline Kontur @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` skal være et heltal mellem {min} og {max} (fik {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` skal være mellem {min} og {max} (fik {x}) @msg `{key}` must be one of {_1} `{key}` skal være en af {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` skal være en af {_1}, ikke {got} @msg `{key}` must be true or false `{key}` skal være true eller false @msg `{k}` must be [x, y] `{k}` skal være [x, y] @msg `{name}` has no options `{name}` har ingen indstillinger diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1d3b12f63a..0e732c8be0 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5914,6 +5914,35 @@ axis Y Axis Y-Achse @msg the path has no anchor points der Pfad hat keine Ankerpunkte @msg too many anchor points (limit {MAX_ANCHORS}) zu viele Ankerpunkte (Grenze {MAX_ANCHORS}) @msg true or false true oder false +@msg a number >= {_1} eine Zahl >= {_1} +@msg a number > {_1} eine Zahl > {_1} +@msg a number <= {_1} eine Zahl <= {_1} +@msg a number < {_1} eine Zahl < {_1} +@msg a number {_1} to {_2} eine Zahl von {_1} bis {_2} +@msg a number >= {_1} and < {_2} eine Zahl >= {_1} und < {_2} +@msg a number > {_1} and <= {_2} eine Zahl > {_1} und <= {_2} +@msg a number > {_1} and < {_2} eine Zahl > {_1} und < {_2} +@msg a number ({_1}) eine Zahl ({_1}) +@msg a number >= {_1} ({_2}) eine Zahl >= {_1} ({_2}) +@msg a number > {_1} ({_2}) eine Zahl > {_1} ({_2}) +@msg a number <= {_1} ({_2}) eine Zahl <= {_1} ({_2}) +@msg a number < {_1} ({_2}) eine Zahl < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) eine Zahl von {_1} bis {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) eine Zahl >= {_1} und < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) eine Zahl > {_1} und <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) eine Zahl > {_1} und < {_2} ({_3}) +@msg an integer eine ganze Zahl +@msg an integer >= {_1} eine ganze Zahl >= {_1} +@msg an integer <= {_1} eine ganze Zahl <= {_1} +@msg an integer {_1} to {_2} eine ganze Zahl von {_1} bis {_2} +@msg one of {_1} einer der Werte {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` muss einer der Werte {_1} sein, nicht {got} +@msg an object id (a non-negative integer) eine Objekt-ID (eine nicht negative ganze Zahl) +@msg a list of object ids eine Liste von Objekt-IDs +@msg a color (a string, [r, g, b] or an object) eine Farbe (eine Zeichenkette, [r, g, b] oder ein Objekt) +@msg [x, y] (two numbers) [x, y] (zwei Zahlen) +@msg an object ein Objekt +@msg a list eine Liste @msg unknown key `{k}`; the keys here are {_1} unbekannter Schlüssel `{k}`; die Schlüssel hier sind {_1} @msg {_1} is listed twice {_1} ist doppelt aufgeführt @msg {_1} is not the id of an input object {_1} ist nicht die ID eines Eingabeobjekts diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8467b8e831..59d243f120 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6274,6 +6274,35 @@ axis Y Axis Άξονας Y @msg the path has no anchor points η διαδρομή δεν έχει σημεία αγκύρωσης @msg too many anchor points (limit {MAX_ANCHORS}) πάρα πολλά σημεία αγκύρωσης (όριο {MAX_ANCHORS}) @msg true or false true ή false +@msg a number >= {_1} αριθμός >= {_1} +@msg a number > {_1} αριθμός > {_1} +@msg a number <= {_1} αριθμός <= {_1} +@msg a number < {_1} αριθμός < {_1} +@msg a number {_1} to {_2} αριθμός από {_1} έως {_2} +@msg a number >= {_1} and < {_2} αριθμός >= {_1} και < {_2} +@msg a number > {_1} and <= {_2} αριθμός > {_1} και <= {_2} +@msg a number > {_1} and < {_2} αριθμός > {_1} και < {_2} +@msg a number ({_1}) αριθμός ({_1}) +@msg a number >= {_1} ({_2}) αριθμός >= {_1} ({_2}) +@msg a number > {_1} ({_2}) αριθμός > {_1} ({_2}) +@msg a number <= {_1} ({_2}) αριθμός <= {_1} ({_2}) +@msg a number < {_1} ({_2}) αριθμός < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) αριθμός από {_1} έως {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) αριθμός >= {_1} και < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) αριθμός > {_1} και <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) αριθμός > {_1} και < {_2} ({_3}) +@msg an integer ακέραιος +@msg an integer >= {_1} ακέραιος >= {_1} +@msg an integer <= {_1} ακέραιος <= {_1} +@msg an integer {_1} to {_2} ακέραιος από {_1} έως {_2} +@msg one of {_1} μία από τις τιμές {_1} +@msg `{path}` must be one of {_1}, not {got} Το `{path}` πρέπει να είναι μία από τις τιμές {_1}, όχι {got} +@msg an object id (a non-negative integer) αναγνωριστικό αντικειμένου (μη αρνητικός ακέραιος) +@msg a list of object ids λίστα αναγνωριστικών αντικειμένων +@msg a color (a string, [r, g, b] or an object) χρώμα (συμβολοσειρά, [r, g, b] ή αντικείμενο) +@msg [x, y] (two numbers) [x, y] (δύο αριθμοί) +@msg an object αντικείμενο +@msg a list λίστα @msg unknown key `{k}`; the keys here are {_1} άγνωστο κλειδί `{k}`· τα κλειδιά εδώ είναι {_1} @msg {_1} is listed twice το {_1} αναφέρεται δύο φορές @msg {_1} is not the id of an input object το {_1} δεν είναι αναγνωριστικό αντικειμένου εισόδου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 194ba2c602..c4180a77ec 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6265,6 +6265,35 @@ axis Y Axis Eje Y @msg the path has no anchor points el trazado no tiene puntos de ancla @msg too many anchor points (limit {MAX_ANCHORS}) demasiados puntos de ancla (límite {MAX_ANCHORS}) @msg true or false true o false +@msg a number >= {_1} un número >= {_1} +@msg a number > {_1} un número > {_1} +@msg a number <= {_1} un número <= {_1} +@msg a number < {_1} un número < {_1} +@msg a number {_1} to {_2} un número de {_1} a {_2} +@msg a number >= {_1} and < {_2} un número >= {_1} y < {_2} +@msg a number > {_1} and <= {_2} un número > {_1} y <= {_2} +@msg a number > {_1} and < {_2} un número > {_1} y < {_2} +@msg a number ({_1}) un número ({_1}) +@msg a number >= {_1} ({_2}) un número >= {_1} ({_2}) +@msg a number > {_1} ({_2}) un número > {_1} ({_2}) +@msg a number <= {_1} ({_2}) un número <= {_1} ({_2}) +@msg a number < {_1} ({_2}) un número < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) un número de {_1} a {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) un número >= {_1} y < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) un número > {_1} y <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) un número > {_1} y < {_2} ({_3}) +@msg an integer un entero +@msg an integer >= {_1} un entero >= {_1} +@msg an integer <= {_1} un entero <= {_1} +@msg an integer {_1} to {_2} un entero de {_1} a {_2} +@msg one of {_1} uno de {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` debe ser uno de {_1}, no {got} +@msg an object id (a non-negative integer) un id. de objeto (un entero no negativo) +@msg a list of object ids una lista de id. de objeto +@msg a color (a string, [r, g, b] or an object) un color (una cadena, [r, g, b] o un objeto) +@msg [x, y] (two numbers) [x, y] (dos números) +@msg an object un objeto +@msg a list una lista @msg unknown key `{k}`; the keys here are {_1} clave desconocida `{k}`; las claves aquí son {_1} @msg {_1} is listed twice {_1} aparece dos veces @msg {_1} is not the id of an input object {_1} no es el id de un objeto de entrada diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e36c5e891e..365003fc51 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6353,6 +6353,35 @@ axis Y Axis Y-akseli @msg the path has no anchor points polulla ei ole ankkuripisteitä @msg too many anchor points (limit {MAX_ANCHORS}) liian monta ankkuripistettä (raja {MAX_ANCHORS}) @msg true or false true tai false +@msg a number >= {_1} luku >= {_1} +@msg a number > {_1} luku > {_1} +@msg a number <= {_1} luku <= {_1} +@msg a number < {_1} luku < {_1} +@msg a number {_1} to {_2} luku, joka on vähintään {_1} ja enintään {_2} +@msg a number >= {_1} and < {_2} luku >= {_1} ja < {_2} +@msg a number > {_1} and <= {_2} luku > {_1} ja <= {_2} +@msg a number > {_1} and < {_2} luku > {_1} ja < {_2} +@msg a number ({_1}) luku ({_1}) +@msg a number >= {_1} ({_2}) luku >= {_1} ({_2}) +@msg a number > {_1} ({_2}) luku > {_1} ({_2}) +@msg a number <= {_1} ({_2}) luku <= {_1} ({_2}) +@msg a number < {_1} ({_2}) luku < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) luku, joka on vähintään {_1} ja enintään {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) luku >= {_1} ja < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) luku > {_1} ja <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) luku > {_1} ja < {_2} ({_3}) +@msg an integer kokonaisluku +@msg an integer >= {_1} kokonaisluku >= {_1} +@msg an integer <= {_1} kokonaisluku <= {_1} +@msg an integer {_1} to {_2} kokonaisluku, joka on vähintään {_1} ja enintään {_2} +@msg one of {_1} jokin arvoista {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` on oltava jokin arvoista {_1}, ei {got} +@msg an object id (a non-negative integer) objektitunnus (ei-negatiivinen kokonaisluku) +@msg a list of object ids objektitunnusten luettelo +@msg a color (a string, [r, g, b] or an object) väri (merkkijono, [r, g, b] tai objekti) +@msg [x, y] (two numbers) [x, y] (kaksi lukua) +@msg an object objekti +@msg a list luettelo @msg unknown key `{k}`; the keys here are {_1} tuntematon avain `{k}`; tässä käytettävät avaimet ovat {_1} @msg {_1} is listed twice {_1} on luettelossa kahdesti @msg {_1} is not the id of an input object {_1} ei ole syöteobjektin tunnus diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 067845f493..8a3ee3e111 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6318,6 +6318,35 @@ axis Y Axis Axe Y @msg the path has no anchor points le tracé n’a aucun point d’ancrage @msg too many anchor points (limit {MAX_ANCHORS}) trop de points d’ancrage (limite {MAX_ANCHORS}) @msg true or false true ou false +@msg a number >= {_1} un nombre >= {_1} +@msg a number > {_1} un nombre > {_1} +@msg a number <= {_1} un nombre <= {_1} +@msg a number < {_1} un nombre < {_1} +@msg a number {_1} to {_2} un nombre de {_1} à {_2} +@msg a number >= {_1} and < {_2} un nombre >= {_1} et < {_2} +@msg a number > {_1} and <= {_2} un nombre > {_1} et <= {_2} +@msg a number > {_1} and < {_2} un nombre > {_1} et < {_2} +@msg a number ({_1}) un nombre ({_1}) +@msg a number >= {_1} ({_2}) un nombre >= {_1} ({_2}) +@msg a number > {_1} ({_2}) un nombre > {_1} ({_2}) +@msg a number <= {_1} ({_2}) un nombre <= {_1} ({_2}) +@msg a number < {_1} ({_2}) un nombre < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) un nombre de {_1} à {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) un nombre >= {_1} et < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) un nombre > {_1} et <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) un nombre > {_1} et < {_2} ({_3}) +@msg an integer un entier +@msg an integer >= {_1} un entier >= {_1} +@msg an integer <= {_1} un entier <= {_1} +@msg an integer {_1} to {_2} un entier de {_1} à {_2} +@msg one of {_1} l’une des valeurs {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` doit être l’une des valeurs {_1}, pas {got} +@msg an object id (a non-negative integer) un identifiant d’objet (un entier positif ou nul) +@msg a list of object ids une liste d’identifiants d’objet +@msg a color (a string, [r, g, b] or an object) une couleur (une chaîne, [r, g, b] ou un objet) +@msg [x, y] (two numbers) [x, y] (deux nombres) +@msg an object un objet +@msg a list une liste @msg unknown key `{k}`; the keys here are {_1} clé inconnue `{k}` ; les clés ici sont {_1} @msg {_1} is listed twice {_1} figure deux fois @msg {_1} is not the id of an input object {_1} n’est pas l’identifiant d’un objet d’entrée diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 165e04ae7d..cae603977e 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5530,6 +5530,35 @@ antiAlias Sharp शार्प @msg the path has no anchor points पाथ में कोई एंकर पॉइंट नहीं है @msg too many anchor points (limit {MAX_ANCHORS}) बहुत अधिक एंकर पॉइंट (सीमा {MAX_ANCHORS}) @msg true or false true या false +@msg a number >= {_1} एक संख्या >= {_1} +@msg a number > {_1} एक संख्या > {_1} +@msg a number <= {_1} एक संख्या <= {_1} +@msg a number < {_1} एक संख्या < {_1} +@msg a number {_1} to {_2} {_1} से {_2} तक की एक संख्या +@msg a number >= {_1} and < {_2} एक संख्या >= {_1} और < {_2} +@msg a number > {_1} and <= {_2} एक संख्या > {_1} और <= {_2} +@msg a number > {_1} and < {_2} एक संख्या > {_1} और < {_2} +@msg a number ({_1}) एक संख्या ({_1}) +@msg a number >= {_1} ({_2}) एक संख्या >= {_1} ({_2}) +@msg a number > {_1} ({_2}) एक संख्या > {_1} ({_2}) +@msg a number <= {_1} ({_2}) एक संख्या <= {_1} ({_2}) +@msg a number < {_1} ({_2}) एक संख्या < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) {_1} से {_2} तक की एक संख्या ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) एक संख्या >= {_1} और < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) एक संख्या > {_1} और <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) एक संख्या > {_1} और < {_2} ({_3}) +@msg an integer एक पूर्णांक +@msg an integer >= {_1} एक पूर्णांक >= {_1} +@msg an integer <= {_1} एक पूर्णांक <= {_1} +@msg an integer {_1} to {_2} {_1} से {_2} तक का एक पूर्णांक +@msg one of {_1} {_1} में से एक +@msg `{path}` must be one of {_1}, not {got} `{path}` {_1} में से एक होना चाहिए, {got} नहीं +@msg an object id (a non-negative integer) एक ऑब्जेक्ट id (एक गैर-नकारात्मक पूर्णांक) +@msg a list of object ids ऑब्जेक्ट ids की एक सूची +@msg a color (a string, [r, g, b] or an object) एक रंग (एक स्ट्रिंग, [r, g, b] या एक ऑब्जेक्ट) +@msg [x, y] (two numbers) [x, y] (दो संख्याएँ) +@msg an object एक ऑब्जेक्ट +@msg a list एक सूची @msg unknown key `{k}`; the keys here are {_1} अज्ञात कुंजी `{k}`; यहाँ की कुंजियाँ {_1} हैं @msg {_1} is listed twice {_1} दो बार सूचीबद्ध है @msg {_1} is not the id of an input object {_1} किसी इनपुट ऑब्जेक्ट का id नहीं है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2ccf9d57bc..0155edf5b2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6306,6 +6306,35 @@ axis Y Axis Y tengely @msg the path has no anchor points az útvonalnak nincsenek horgonypontjai @msg too many anchor points (limit {MAX_ANCHORS}) túl sok horgonypont (korlát: {MAX_ANCHORS}) @msg true or false true vagy false +@msg a number >= {_1} szám >= {_1} +@msg a number > {_1} szám > {_1} +@msg a number <= {_1} szám <= {_1} +@msg a number < {_1} szám < {_1} +@msg a number {_1} to {_2} {_1} és {_2} közötti szám +@msg a number >= {_1} and < {_2} szám >= {_1} és < {_2} +@msg a number > {_1} and <= {_2} szám > {_1} és <= {_2} +@msg a number > {_1} and < {_2} szám > {_1} és < {_2} +@msg a number ({_1}) szám ({_1}) +@msg a number >= {_1} ({_2}) szám >= {_1} ({_2}) +@msg a number > {_1} ({_2}) szám > {_1} ({_2}) +@msg a number <= {_1} ({_2}) szám <= {_1} ({_2}) +@msg a number < {_1} ({_2}) szám < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) {_1} és {_2} közötti szám ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) szám >= {_1} és < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) szám > {_1} és <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) szám > {_1} és < {_2} ({_3}) +@msg an integer egész szám +@msg an integer >= {_1} egész szám >= {_1} +@msg an integer <= {_1} egész szám <= {_1} +@msg an integer {_1} to {_2} {_1} és {_2} közötti egész szám +@msg one of {_1} {_1} közül az egyik +@msg `{path}` must be one of {_1}, not {got} `{path}`: {_1} közül az egyik kell, nem ez: {got} +@msg an object id (a non-negative integer) objektumazonosító (nemnegatív egész szám) +@msg a list of object ids objektumazonosítók listája +@msg a color (a string, [r, g, b] or an object) szín (karakterlánc, [r, g, b] vagy objektum) +@msg [x, y] (two numbers) [x, y] (két szám) +@msg an object objektum +@msg a list lista @msg unknown key `{k}`; the keys here are {_1} ismeretlen kulcs: `{k}`; az itt használható kulcsok: {_1} @msg {_1} is listed twice {_1} kétszer szerepel @msg {_1} is not the id of an input object {_1} nem egy bemeneti objektum azonosítója diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 16303f38f5..9ef5051464 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4299,6 +4299,7 @@ imageTrace Outline Kontur @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` harus berupa bilangan bulat antara {min} dan {max} (diperoleh {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` harus di antara {min} dan {max} (diperoleh {x}) @msg `{key}` must be one of {_1} `{key}` harus salah satu dari {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` harus salah satu dari {_1}, bukan {got} @msg `{key}` must be true or false `{key}` harus true atau false @msg `{k}` must be [x, y] `{k}` harus [x, y] @msg `{name}` has no options `{name}` tidak memiliki opsi diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index be83d78b43..34d901ec90 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5948,6 +5948,35 @@ axis Y Axis Asse Y @msg the path has no anchor points il tracciato non ha punti di ancoraggio @msg too many anchor points (limit {MAX_ANCHORS}) troppi punti di ancoraggio (limite {MAX_ANCHORS}) @msg true or false true o false +@msg a number >= {_1} un numero >= {_1} +@msg a number > {_1} un numero > {_1} +@msg a number <= {_1} un numero <= {_1} +@msg a number < {_1} un numero < {_1} +@msg a number {_1} to {_2} un numero da {_1} a {_2} +@msg a number >= {_1} and < {_2} un numero >= {_1} e < {_2} +@msg a number > {_1} and <= {_2} un numero > {_1} e <= {_2} +@msg a number > {_1} and < {_2} un numero > {_1} e < {_2} +@msg a number ({_1}) un numero ({_1}) +@msg a number >= {_1} ({_2}) un numero >= {_1} ({_2}) +@msg a number > {_1} ({_2}) un numero > {_1} ({_2}) +@msg a number <= {_1} ({_2}) un numero <= {_1} ({_2}) +@msg a number < {_1} ({_2}) un numero < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) un numero da {_1} a {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) un numero >= {_1} e < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) un numero > {_1} e <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) un numero > {_1} e < {_2} ({_3}) +@msg an integer un intero +@msg an integer >= {_1} un intero >= {_1} +@msg an integer <= {_1} un intero <= {_1} +@msg an integer {_1} to {_2} un intero da {_1} a {_2} +@msg one of {_1} uno tra {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` deve essere uno tra {_1}, non {got} +@msg an object id (a non-negative integer) un ID di oggetto (un intero non negativo) +@msg a list of object ids un elenco di ID di oggetti +@msg a color (a string, [r, g, b] or an object) un colore (una stringa, [r, g, b] o un oggetto) +@msg [x, y] (two numbers) [x, y] (due numeri) +@msg an object un oggetto +@msg a list un elenco @msg unknown key `{k}`; the keys here are {_1} chiave sconosciuta `{k}`; le chiavi qui sono {_1} @msg {_1} is listed twice {_1} è elencato due volte @msg {_1} is not the id of an input object {_1} non è l’id di un oggetto di input diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b27c6d2698..027b262681 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6013,6 +6013,35 @@ axis Y Axis Y 軸 @msg the path has no anchor points パスにアンカーポイントがありません @msg too many anchor points (limit {MAX_ANCHORS}) アンカーポイントが多すぎます (上限 {MAX_ANCHORS}) @msg true or false true または false +@msg a number >= {_1} {_1} 以上の数値 +@msg a number > {_1} {_1} より大きい数値 +@msg a number <= {_1} {_1} 以下の数値 +@msg a number < {_1} {_1} 未満の数値 +@msg a number {_1} to {_2} {_1} から {_2} までの数値 +@msg a number >= {_1} and < {_2} {_1} 以上 {_2} 未満の数値 +@msg a number > {_1} and <= {_2} {_1} より大きく {_2} 以下の数値 +@msg a number > {_1} and < {_2} {_1} より大きく {_2} 未満の数値 +@msg a number ({_1}) 数値 ({_1}) +@msg a number >= {_1} ({_2}) {_1} 以上の数値 ({_2}) +@msg a number > {_1} ({_2}) {_1} より大きい数値 ({_2}) +@msg a number <= {_1} ({_2}) {_1} 以下の数値 ({_2}) +@msg a number < {_1} ({_2}) {_1} 未満の数値 ({_2}) +@msg a number {_1} to {_2} ({_3}) {_1} から {_2} までの数値 ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) {_1} 以上 {_2} 未満の数値 ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) {_1} より大きく {_2} 以下の数値 ({_3}) +@msg a number > {_1} and < {_2} ({_3}) {_1} より大きく {_2} 未満の数値 ({_3}) +@msg an integer 整数 +@msg an integer >= {_1} {_1} 以上の整数 +@msg an integer <= {_1} {_1} 以下の整数 +@msg an integer {_1} to {_2} {_1} から {_2} までの整数 +@msg one of {_1} {_1} のいずれか +@msg `{path}` must be one of {_1}, not {got} `{path}` は {_1} のいずれかにしてください ({got} は使えません) +@msg an object id (a non-negative integer) オブジェクト ID (0 以上の整数) +@msg a list of object ids オブジェクト ID のリスト +@msg a color (a string, [r, g, b] or an object) カラー (文字列、[r, g, b] またはオブジェクト) +@msg [x, y] (two numbers) [x, y] (2 つの数値) +@msg an object オブジェクト +@msg a list リスト @msg unknown key `{k}`; the keys here are {_1} `{k}` は不明なキーです。ここで使えるキー: {_1} @msg {_1} is listed twice {_1} が 2 回指定されています @msg {_1} is not the id of an input object {_1} は入力オブジェクトの ID ではありません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 4ba40baab0..e49e6cd4b8 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6274,6 +6274,35 @@ axis Y Axis Y ღერძი @msg the path has no anchor points ტრაექტორიას საყრდენი წერტილები არ აქვს @msg too many anchor points (limit {MAX_ANCHORS}) ძალიან ბევრი საყრდენი წერტილი (ლიმიტი {MAX_ANCHORS}) @msg true or false true ან false +@msg a number >= {_1} რიცხვი >= {_1} +@msg a number > {_1} რიცხვი > {_1} +@msg a number <= {_1} რიცხვი <= {_1} +@msg a number < {_1} რიცხვი < {_1} +@msg a number {_1} to {_2} რიცხვი {_1}-დან {_2}-მდე +@msg a number >= {_1} and < {_2} რიცხვი >= {_1} და < {_2} +@msg a number > {_1} and <= {_2} რიცხვი > {_1} და <= {_2} +@msg a number > {_1} and < {_2} რიცხვი > {_1} და < {_2} +@msg a number ({_1}) რიცხვი ({_1}) +@msg a number >= {_1} ({_2}) რიცხვი >= {_1} ({_2}) +@msg a number > {_1} ({_2}) რიცხვი > {_1} ({_2}) +@msg a number <= {_1} ({_2}) რიცხვი <= {_1} ({_2}) +@msg a number < {_1} ({_2}) რიცხვი < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) რიცხვი {_1}-დან {_2}-მდე ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) რიცხვი >= {_1} და < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) რიცხვი > {_1} და <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) რიცხვი > {_1} და < {_2} ({_3}) +@msg an integer მთელი რიცხვი +@msg an integer >= {_1} მთელი რიცხვი >= {_1} +@msg an integer <= {_1} მთელი რიცხვი <= {_1} +@msg an integer {_1} to {_2} მთელი რიცხვი {_1}-დან {_2}-მდე +@msg one of {_1} ერთ-ერთი მნიშვნელობა {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` უნდა იყოს ერთ-ერთი მნიშვნელობა {_1} და არა {got} +@msg an object id (a non-negative integer) ობიექტის იდენტიფიკატორი (არაუარყოფითი მთელი რიცხვი) +@msg a list of object ids ობიექტების იდენტიფიკატორების სია +@msg a color (a string, [r, g, b] or an object) ფერი (სტრიქონი, [r, g, b] ან ობიექტი) +@msg [x, y] (two numbers) [x, y] (ორი რიცხვი) +@msg an object ობიექტი +@msg a list სია @msg unknown key `{k}`; the keys here are {_1} უცნობი გასაღები `{k}`; აქ გასაღებებია: {_1} @msg {_1} is listed twice {_1} ორჯერაა მითითებული @msg {_1} is not the id of an input object {_1} შემავალი ობიექტის ID არ არის diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c46ce6b4fa..206d70f302 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5909,6 +5909,35 @@ axis Y Axis Y축 @msg the path has no anchor points 패스에 앵커 포인트가 없습니다 @msg too many anchor points (limit {MAX_ANCHORS}) 앵커 포인트가 너무 많습니다(한도 {MAX_ANCHORS}) @msg true or false true 또는 false +@msg a number >= {_1} {_1} 이상인 숫자 +@msg a number > {_1} {_1} 초과인 숫자 +@msg a number <= {_1} {_1} 이하인 숫자 +@msg a number < {_1} {_1} 미만인 숫자 +@msg a number {_1} to {_2} {_1} 이상 {_2} 이하인 숫자 +@msg a number >= {_1} and < {_2} {_1} 이상 {_2} 미만인 숫자 +@msg a number > {_1} and <= {_2} {_1} 초과 {_2} 이하인 숫자 +@msg a number > {_1} and < {_2} {_1} 초과 {_2} 미만인 숫자 +@msg a number ({_1}) 숫자({_1}) +@msg a number >= {_1} ({_2}) {_1} 이상인 숫자({_2}) +@msg a number > {_1} ({_2}) {_1} 초과인 숫자({_2}) +@msg a number <= {_1} ({_2}) {_1} 이하인 숫자({_2}) +@msg a number < {_1} ({_2}) {_1} 미만인 숫자({_2}) +@msg a number {_1} to {_2} ({_3}) {_1} 이상 {_2} 이하인 숫자({_3}) +@msg a number >= {_1} and < {_2} ({_3}) {_1} 이상 {_2} 미만인 숫자({_3}) +@msg a number > {_1} and <= {_2} ({_3}) {_1} 초과 {_2} 이하인 숫자({_3}) +@msg a number > {_1} and < {_2} ({_3}) {_1} 초과 {_2} 미만인 숫자({_3}) +@msg an integer 정수 +@msg an integer >= {_1} {_1} 이상인 정수 +@msg an integer <= {_1} {_1} 이하인 정수 +@msg an integer {_1} to {_2} {_1} 이상 {_2} 이하인 정수 +@msg one of {_1} {_1} 중 하나 +@msg `{path}` must be one of {_1}, not {got} `{path}`은(는) {_1} 중 하나여야 합니다({got} 사용 불가) +@msg an object id (a non-negative integer) 개체 ID(음수가 아닌 정수) +@msg a list of object ids 개체 ID 목록 +@msg a color (a string, [r, g, b] or an object) 색상(문자열, [r, g, b] 또는 개체) +@msg [x, y] (two numbers) [x, y](숫자 두 개) +@msg an object 개체 +@msg a list 목록 @msg unknown key `{k}`; the keys here are {_1} 알 수 없는 키 `{k}`: 여기서 사용할 수 있는 키: {_1} @msg {_1} is listed twice {_1}이(가) 두 번 나열되었습니다 @msg {_1} is not the id of an input object {_1}은(는) 입력 개체의 ID가 아닙니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index a19e190ed6..87961019c8 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4313,6 +4313,7 @@ imageTrace Outline Contour @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` moet een geheel getal tussen {min} en {max} zijn (kreeg {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` moet tussen {min} en {max} liggen (kreeg {x}) @msg `{key}` must be one of {_1} `{key}` moet een van {_1} zijn +@msg `{path}` must be one of {_1}, not {got} `{path}` moet een van {_1} zijn, niet {got} @msg `{key}` must be true or false `{key}` moet true of false zijn @msg `{k}` must be [x, y] `{k}` moet [x, y] zijn @msg `{name}` has no options `{name}` heeft geen opties diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a232ac4e8d..3121bfbb61 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6177,6 +6177,35 @@ axis Y Axis Oś Y @msg the path has no anchor points ścieżka nie ma punktów kontrolnych @msg too many anchor points (limit {MAX_ANCHORS}) za dużo punktów kontrolnych (limit {MAX_ANCHORS}) @msg true or false true lub false +@msg a number >= {_1} liczbą >= {_1} +@msg a number > {_1} liczbą > {_1} +@msg a number <= {_1} liczbą <= {_1} +@msg a number < {_1} liczbą < {_1} +@msg a number {_1} to {_2} liczbą od {_1} do {_2} +@msg a number >= {_1} and < {_2} liczbą >= {_1} i < {_2} +@msg a number > {_1} and <= {_2} liczbą > {_1} i <= {_2} +@msg a number > {_1} and < {_2} liczbą > {_1} i < {_2} +@msg a number ({_1}) liczbą ({_1}) +@msg a number >= {_1} ({_2}) liczbą >= {_1} ({_2}) +@msg a number > {_1} ({_2}) liczbą > {_1} ({_2}) +@msg a number <= {_1} ({_2}) liczbą <= {_1} ({_2}) +@msg a number < {_1} ({_2}) liczbą < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) liczbą od {_1} do {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) liczbą >= {_1} i < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) liczbą > {_1} i <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) liczbą > {_1} i < {_2} ({_3}) +@msg an integer liczbą całkowitą +@msg an integer >= {_1} liczbą całkowitą >= {_1} +@msg an integer <= {_1} liczbą całkowitą <= {_1} +@msg an integer {_1} to {_2} liczbą całkowitą od {_1} do {_2} +@msg one of {_1} jedną z wartości {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` musi być jedną z wartości {_1}, a nie {got} +@msg an object id (a non-negative integer) identyfikatorem obiektu (nieujemną liczbą całkowitą) +@msg a list of object ids listą identyfikatorów obiektów +@msg a color (a string, [r, g, b] or an object) kolorem (ciągiem znaków, [r, g, b] lub obiektem) +@msg [x, y] (two numbers) [x, y] (dwiema liczbami) +@msg an object obiektem +@msg a list listą @msg unknown key `{k}`; the keys here are {_1} nieznany klucz `{k}`; klucze w tym miejscu to {_1} @msg {_1} is listed twice {_1} występuje dwa razy @msg {_1} is not the id of an input object {_1} nie jest identyfikatorem obiektu wejściowego diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 68a225625d..d6f98a96e4 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6238,6 +6238,35 @@ axis Y Axis Eixo Y @msg the path has no anchor points o caminho não tem pontos de ancoragem @msg too many anchor points (limit {MAX_ANCHORS}) pontos de ancoragem demais (limite {MAX_ANCHORS}) @msg true or false true ou false +@msg a number >= {_1} um número >= {_1} +@msg a number > {_1} um número > {_1} +@msg a number <= {_1} um número <= {_1} +@msg a number < {_1} um número < {_1} +@msg a number {_1} to {_2} um número de {_1} a {_2} +@msg a number >= {_1} and < {_2} um número >= {_1} e < {_2} +@msg a number > {_1} and <= {_2} um número > {_1} e <= {_2} +@msg a number > {_1} and < {_2} um número > {_1} e < {_2} +@msg a number ({_1}) um número ({_1}) +@msg a number >= {_1} ({_2}) um número >= {_1} ({_2}) +@msg a number > {_1} ({_2}) um número > {_1} ({_2}) +@msg a number <= {_1} ({_2}) um número <= {_1} ({_2}) +@msg a number < {_1} ({_2}) um número < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) um número de {_1} a {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) um número >= {_1} e < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) um número > {_1} e <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) um número > {_1} e < {_2} ({_3}) +@msg an integer um inteiro +@msg an integer >= {_1} um inteiro >= {_1} +@msg an integer <= {_1} um inteiro <= {_1} +@msg an integer {_1} to {_2} um inteiro de {_1} a {_2} +@msg one of {_1} um de {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` deve ser um de {_1}, não {got} +@msg an object id (a non-negative integer) um ID de objeto (um inteiro não negativo) +@msg a list of object ids uma lista de IDs de objeto +@msg a color (a string, [r, g, b] or an object) uma cor (uma string, [r, g, b] ou um objeto) +@msg [x, y] (two numbers) [x, y] (dois números) +@msg an object um objeto +@msg a list uma lista @msg unknown key `{k}`; the keys here are {_1} chave desconhecida `{k}`; as chaves aqui são {_1} @msg {_1} is listed twice {_1} aparece duas vezes @msg {_1} is not the id of an input object {_1} não é o id de um objeto de entrada diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a54d1eed2c..13512a6070 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5934,6 +5934,35 @@ axis Y Axis Ось Y @msg the path has no anchor points у контура нет опорных точек @msg too many anchor points (limit {MAX_ANCHORS}) слишком много опорных точек (предел {MAX_ANCHORS}) @msg true or false true или false +@msg a number >= {_1} число >= {_1} +@msg a number > {_1} число > {_1} +@msg a number <= {_1} число <= {_1} +@msg a number < {_1} число < {_1} +@msg a number {_1} to {_2} число от {_1} до {_2} +@msg a number >= {_1} and < {_2} число >= {_1} и < {_2} +@msg a number > {_1} and <= {_2} число > {_1} и <= {_2} +@msg a number > {_1} and < {_2} число > {_1} и < {_2} +@msg a number ({_1}) число ({_1}) +@msg a number >= {_1} ({_2}) число >= {_1} ({_2}) +@msg a number > {_1} ({_2}) число > {_1} ({_2}) +@msg a number <= {_1} ({_2}) число <= {_1} ({_2}) +@msg a number < {_1} ({_2}) число < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) число от {_1} до {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) число >= {_1} и < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) число > {_1} и <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) число > {_1} и < {_2} ({_3}) +@msg an integer целое число +@msg an integer >= {_1} целое число >= {_1} +@msg an integer <= {_1} целое число <= {_1} +@msg an integer {_1} to {_2} целое число от {_1} до {_2} +@msg one of {_1} одно из значений {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` должно быть одним из значений {_1}, а не {got} +@msg an object id (a non-negative integer) идентификатор объекта (неотрицательное целое число) +@msg a list of object ids список идентификаторов объектов +@msg a color (a string, [r, g, b] or an object) цвет (строка, [r, g, b] или объект) +@msg [x, y] (two numbers) [x, y] (два числа) +@msg an object объект +@msg a list список @msg unknown key `{k}`; the keys here are {_1} неизвестный ключ `{k}`; здесь допустимы ключи {_1} @msg {_1} is listed twice {_1} указан дважды @msg {_1} is not the id of an input object {_1} не является идентификатором входного объекта diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index d0648b5946..5f82d23f68 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6309,6 +6309,35 @@ axis Y Axis Y-axel @msg the path has no anchor points banan har inga ankarpunkter @msg too many anchor points (limit {MAX_ANCHORS}) för många ankarpunkter (gräns {MAX_ANCHORS}) @msg true or false true eller false +@msg a number >= {_1} ett tal >= {_1} +@msg a number > {_1} ett tal > {_1} +@msg a number <= {_1} ett tal <= {_1} +@msg a number < {_1} ett tal < {_1} +@msg a number {_1} to {_2} ett tal från {_1} till {_2} +@msg a number >= {_1} and < {_2} ett tal >= {_1} och < {_2} +@msg a number > {_1} and <= {_2} ett tal > {_1} och <= {_2} +@msg a number > {_1} and < {_2} ett tal > {_1} och < {_2} +@msg a number ({_1}) ett tal ({_1}) +@msg a number >= {_1} ({_2}) ett tal >= {_1} ({_2}) +@msg a number > {_1} ({_2}) ett tal > {_1} ({_2}) +@msg a number <= {_1} ({_2}) ett tal <= {_1} ({_2}) +@msg a number < {_1} ({_2}) ett tal < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) ett tal från {_1} till {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) ett tal >= {_1} och < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) ett tal > {_1} och <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) ett tal > {_1} och < {_2} ({_3}) +@msg an integer ett heltal +@msg an integer >= {_1} ett heltal >= {_1} +@msg an integer <= {_1} ett heltal <= {_1} +@msg an integer {_1} to {_2} ett heltal från {_1} till {_2} +@msg one of {_1} ett av {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` måste vara ett av {_1}, inte {got} +@msg an object id (a non-negative integer) ett objekt-id (ett icke-negativt heltal) +@msg a list of object ids en lista med objekt-id:n +@msg a color (a string, [r, g, b] or an object) en färg (en sträng, [r, g, b] eller ett objekt) +@msg [x, y] (two numbers) [x, y] (två tal) +@msg an object ett objekt +@msg a list en lista @msg unknown key `{k}`; the keys here are {_1} okänd nyckel `{k}`; nycklarna här är {_1} @msg {_1} is listed twice {_1} finns två gånger @msg {_1} is not the id of an input object {_1} är inte id:t för ett indataobjekt diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 887eb4253b..70dfe0ab90 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -2704,6 +2704,14 @@ fn parameter_schema_errors_are_translated() { s.execute("file.new", &json!({})).unwrap(); let errors = [ (s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": -1})).unwrap_err().to_string(), "`zoom`", "must be", "-1"), + (s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": -1})).unwrap_err().to_string(), "`zoom`", "a number > 0", "-1"), + (s.execute("view.saved.new", &json!({"center": 5, "zoom": 1})).unwrap_err().to_string(), "`center`", "two numbers", "5"), + ( + s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": 1, "rotation": "x"})).unwrap_err().to_string(), + "`rotation`", + "a number (deg)", + "\"x\"", + ), (s.execute("view.saved.new", &json!({"center": [0, 0]})).unwrap_err().to_string(), "`zoom`", "missing", ""), (s.execute("view.saved.list", &json!(5)).unwrap_err().to_string(), "5", "must be", ""), ]; @@ -2725,6 +2733,152 @@ fn parameter_schema_errors_are_translated() { } } +/// The phrases a parameter schema's error uses for what a value must be, as `@msg` sources: one +/// for each type, and for a number each range and unit form. +const SCHEMA_PHRASES: &[&str] = &[ + "a number", + "a number >= {_1}", + "a number > {_1}", + "a number <= {_1}", + "a number < {_1}", + "a number {_1} to {_2}", + "a number >= {_1} and < {_2}", + "a number > {_1} and <= {_2}", + "a number > {_1} and < {_2}", + "a number ({_1})", + "a number >= {_1} ({_2})", + "a number > {_1} ({_2})", + "a number <= {_1} ({_2})", + "a number < {_1} ({_2})", + "a number {_1} to {_2} ({_3})", + "a number >= {_1} and < {_2} ({_3})", + "a number > {_1} and <= {_2} ({_3})", + "a number > {_1} and < {_2} ({_3})", + "an integer", + "an integer >= {_1}", + "an integer <= {_1}", + "an integer {_1} to {_2}", + "true or false", + "a string", + "one of {_1}", + "an object id (a non-negative integer)", + "a list of object ids", + "a color (a string, [r, g, b] or an object)", + "[x, y] (two numbers)", + "an object", + "a list", +]; + +/// What a value of each parameter type must be is translated in every language that translates +/// every message, for every range and unit of a number: the matched row's placeholders take the +/// bounds, the unit and the names exactly as the English gives them, and the whole error keeps +/// the parameter's path and the refused value. +#[test] +fn parameter_schema_expected_phrases_are_translated() { + use serde_json::{Value, json}; + use vectorcraft_apischema::{Bound, Param, ParamSchema, Problem, Type}; + let number = |min, max, unit| Type::Number { min, max, unit }; + let (incl, excl) = (|x| Some(Bound::Inclusive(x)), |x| Some(Bound::Exclusive(x))); + // (type, a value it refuses, the placeholder values of the phrase's row) + let cases: Vec<(Type, Value, Vec<&str>)> = vec![ + (Type::NUMBER, json!("x"), vec![]), + (number(incl(-2.5), None, None), json!(-3), vec!["-2.5"]), + (number(excl(0.0), None, None), json!(0), vec!["0"]), + (number(None, incl(1.5), None), json!(2), vec!["1.5"]), + (number(None, excl(100.0), None), json!(100), vec!["100"]), + (number(incl(-1.0), incl(1.0), None), json!(2), vec!["-1", "1"]), + (number(incl(0.0), excl(360.0), None), json!(360), vec!["0", "360"]), + (number(excl(0.0), incl(1.0), None), json!(0), vec!["0", "1"]), + (number(excl(-0.5), excl(0.5), None), json!(1), vec!["-0.5", "0.5"]), + (number(None, None, Some("deg")), json!("x"), vec!["deg"]), + (number(incl(0.25), None, Some("pt")), json!(0), vec!["0.25", "pt"]), + (number(excl(0.0), None, Some("pt")), json!(0), vec!["0", "pt"]), + (number(None, incl(-1.0), Some("mm")), json!(0), vec!["-1", "mm"]), + (number(None, excl(10.0), Some("px")), json!(10), vec!["10", "px"]), + (number(incl(0.0), incl(100.0), Some("%")), json!(101), vec!["0", "100", "%"]), + (number(incl(-180.0), excl(180.0), Some("deg")), json!(180), vec!["-180", "180", "deg"]), + (number(excl(0.0), incl(1000.0), Some("pt")), json!(0), vec!["0", "1000", "pt"]), + (number(excl(-1.0), excl(1.0), Some("in")), json!(1), vec!["-1", "1", "in"]), + (Type::Integer { min: None, max: None }, json!(1.5), vec![]), + (Type::Integer { min: Some(1), max: None }, json!(0), vec!["1"]), + (Type::Integer { min: None, max: Some(-1) }, json!(0), vec!["-1"]), + (Type::Integer { min: Some(1), max: Some(25) }, json!(26), vec!["1", "25"]), + (Type::Boolean, json!(1), vec![]), + (Type::String, json!(1), vec![]), + (Type::Enum(&["left", "center", "right"]), json!("top"), vec![r#""left", "center", "right""#]), + (Type::Enum(&["only"]), json!("other"), vec![r#""only""#]), + (Type::Id, json!(-1), vec![]), + (Type::Ids, json!(5), vec![]), + (Type::Color, json!(5), vec![]), + (Type::Point, json!(5), vec![]), + (Type::Object(&[]), json!(5), vec![]), + (Type::Array(&Type::NUMBER), json!(5), vec![]), + ]; + let one = |name: &'static str, ty: Type| ParamSchema::new(Box::leak(Box::new([Param::new(name, ty)])), ""); + let inner: &'static [Param] = Box::leak(Box::new([Param::new("zoom", number(excl(0.0), None, None))])); + let nested = one("edits", Type::Array(Box::leak(Box::new(Type::Object(inner))))); + let mut errors: Vec<_> = cases.iter().map(|(ty, bad, caps)| (one("x", *ty).validate(&json!({ "x": bad })), caps.clone())).collect(); + errors.push((nested.validate(&json!({"edits": [{"zoom": 1}, {"zoom": -1}]})), vec!["0"])); + let mut seen = std::collections::BTreeSet::new(); + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let (entries, _) = parse_entries(lang.0.source); + for phrase in SCHEMA_PHRASES { + assert!(entries.iter().any(|(ctx, src, _)| ctx == "@msg" && src == phrase), "{code}: no row for {phrase:?}"); + } + for (result, caps) in &errors { + let Err(e) = result else { panic!("accepted: {result:?}") }; + let Problem::WrongType { expected, got } = &e.problem else { panic!("{e}") }; + seen.insert(expected.clone()); + let (_, matched) = lang.catalog().message(expected).unwrap_or_else(|| panic!("{code}: no row matches {expected:?}")); + let values: Vec<&str> = matched.iter().map(|(_, v)| *v).collect(); + assert_eq!(&values, caps, "{code}: {expected:?}"); + let phrase = message(lang, expected); + assert_ne!(&phrase, expected, "{code}"); + let mut from = 0; + for value in caps { + let at = phrase[from..].find(value).unwrap_or_else(|| panic!("{code}: {value:?} in {expected:?} -> {phrase:?}")); + from += at + value.len(); + } + let shown = message(lang, &e.to_string()); + let path = format!("`{}`", e.path); + let kept = caps.iter().all(|value| shown.contains(value)); + assert!(kept && shown.contains(&path) && shown.contains(got.as_str()), "{code}: {shown}"); + assert!(!holds_word(&shown, expected) && !shown.contains("must be") && !shown.contains(", not "), "{code}: {shown}"); + } + } + // A catalog's `{key}` must be one of {_1}` row would take `"a", "b", not "c"` as its list. + let enum_error = one("x", Type::Enum(&["a", "b"])).validate(&json!({"x": "c"})).map_or_else(|e| e.to_string(), |_| String::new()); + assert_eq!(enum_error, r#"`x` must be one of "a", "b", not "c""#); + for lang in Lang::all().filter(|l| l.code() != Lang::EN.code() && !l.0.source.is_empty()) { + let shown = message(lang, &enum_error); + assert!(!shown.contains(", not ") && shown.contains(r#""a", "b""#) && shown.contains(r#""c""#), "{}: {shown}", lang.code()); + } + for phrase in + ["a number > -0.5 and < 0.5", "a number 0 to 100 (%)", "an integer 1 to 25", r#"one of "left", "center", "right""#, "[x, y] (two numbers)"] + { + assert!(seen.contains(phrase), "{phrase:?} not in {seen:?}"); + } +} + +/// A JSON object or array inside a translated message stays as it is, and an exact message that +/// starts with a bracket is still translated. +#[test] +fn bracketed_message_parts_translate_only_as_exact_messages() { + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let point = message(lang, "[x, y] (two numbers)"); + assert_ne!(point, "[x, y] (two numbers)", "{code}"); + assert!(point.contains("[x, y]"), "{code}: {point}"); + for got in ["[1]", "[1, 2, 3]", r#"{"objects": [1]}"#, r#"[{"a": 1}, "b: c", …"#] { + let shown = message(lang, &format!("`center` must be [x, y] (two numbers), not {got}")); + assert!(shown.contains(&point) && shown.contains(got), "{code}: {shown}"); + let shown = message(lang, &format!("`p` must be an object, not {got}")); + assert!(shown.contains(got) && !shown.contains("an object"), "{code}: {shown}"); + } + } +} + /// The warning for a PDF/X file that leaves out the document's bleed is translated in every catalog, /// alone and in the status bar's note. #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 915a244b43..c86ac108e2 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6279,6 +6279,35 @@ axis Y Axis Y ekseni @msg the path has no anchor points yolun bağlantı noktası yok @msg too many anchor points (limit {MAX_ANCHORS}) çok fazla bağlantı noktası (sınır {MAX_ANCHORS}) @msg true or false true veya false +@msg a number >= {_1} bir sayı >= {_1} +@msg a number > {_1} bir sayı > {_1} +@msg a number <= {_1} bir sayı <= {_1} +@msg a number < {_1} bir sayı < {_1} +@msg a number {_1} to {_2} {_1} ile {_2} arasında bir sayı +@msg a number >= {_1} and < {_2} bir sayı >= {_1} ve < {_2} +@msg a number > {_1} and <= {_2} bir sayı > {_1} ve <= {_2} +@msg a number > {_1} and < {_2} bir sayı > {_1} ve < {_2} +@msg a number ({_1}) bir sayı ({_1}) +@msg a number >= {_1} ({_2}) bir sayı >= {_1} ({_2}) +@msg a number > {_1} ({_2}) bir sayı > {_1} ({_2}) +@msg a number <= {_1} ({_2}) bir sayı <= {_1} ({_2}) +@msg a number < {_1} ({_2}) bir sayı < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) {_1} ile {_2} arasında bir sayı ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) bir sayı >= {_1} ve < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) bir sayı > {_1} ve <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) bir sayı > {_1} ve < {_2} ({_3}) +@msg an integer bir tam sayı +@msg an integer >= {_1} bir tam sayı >= {_1} +@msg an integer <= {_1} bir tam sayı <= {_1} +@msg an integer {_1} to {_2} {_1} ile {_2} arasında bir tam sayı +@msg one of {_1} {_1} değerlerinden biri +@msg `{path}` must be one of {_1}, not {got} `{path}` {_1} değerlerinden biri olmalıdır, {got} değil +@msg an object id (a non-negative integer) bir nesne kimliği (negatif olmayan bir tam sayı) +@msg a list of object ids bir nesne kimlikleri listesi +@msg a color (a string, [r, g, b] or an object) bir renk (bir dize, [r, g, b] veya bir nesne) +@msg [x, y] (two numbers) [x, y] (iki sayı) +@msg an object bir nesne +@msg a list bir liste @msg unknown key `{k}`; the keys here are {_1} bilinmeyen anahtar `{k}`; buradaki anahtarlar: {_1} @msg {_1} is listed twice {_1} iki kez listelenmiş @msg {_1} is not the id of an input object {_1} bir girdi nesnesinin kimliği değil diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e8b5445a50..4601c6ca19 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5928,6 +5928,35 @@ axis Y Axis Вісь Y @msg the path has no anchor points контур не має опорних точок @msg too many anchor points (limit {MAX_ANCHORS}) забагато опорних точок (межа {MAX_ANCHORS}) @msg true or false true або false +@msg a number >= {_1} число >= {_1} +@msg a number > {_1} число > {_1} +@msg a number <= {_1} число <= {_1} +@msg a number < {_1} число < {_1} +@msg a number {_1} to {_2} число від {_1} до {_2} +@msg a number >= {_1} and < {_2} число >= {_1} і < {_2} +@msg a number > {_1} and <= {_2} число > {_1} і <= {_2} +@msg a number > {_1} and < {_2} число > {_1} і < {_2} +@msg a number ({_1}) число ({_1}) +@msg a number >= {_1} ({_2}) число >= {_1} ({_2}) +@msg a number > {_1} ({_2}) число > {_1} ({_2}) +@msg a number <= {_1} ({_2}) число <= {_1} ({_2}) +@msg a number < {_1} ({_2}) число < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) число від {_1} до {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) число >= {_1} і < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) число > {_1} і <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) число > {_1} і < {_2} ({_3}) +@msg an integer ціле число +@msg an integer >= {_1} ціле число >= {_1} +@msg an integer <= {_1} ціле число <= {_1} +@msg an integer {_1} to {_2} ціле число від {_1} до {_2} +@msg one of {_1} одне зі значень {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` має бути одним зі значень {_1}, а не {got} +@msg an object id (a non-negative integer) ідентифікатор об’єкта (невід’ємне ціле число) +@msg a list of object ids список ідентифікаторів об’єктів +@msg a color (a string, [r, g, b] or an object) колір (рядок, [r, g, b] або об’єкт) +@msg [x, y] (two numbers) [x, y] (два числа) +@msg an object об’єкт +@msg a list список @msg unknown key `{k}`; the keys here are {_1} невідомий ключ `{k}`; тут допустимі ключі {_1} @msg {_1} is listed twice {_1} вказано двічі @msg {_1} is not the id of an input object {_1} не є ідентифікатором вхідного об’єкта diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 51681bfed5..6c42f99c33 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5929,6 +5929,35 @@ axis Y Axis Trục Y @msg the path has no anchor points đường dẫn không có điểm neo @msg too many anchor points (limit {MAX_ANCHORS}) quá nhiều điểm neo (giới hạn {MAX_ANCHORS}) @msg true or false true hoặc false +@msg a number >= {_1} một số >= {_1} +@msg a number > {_1} một số > {_1} +@msg a number <= {_1} một số <= {_1} +@msg a number < {_1} một số < {_1} +@msg a number {_1} to {_2} một số từ {_1} đến {_2} +@msg a number >= {_1} and < {_2} một số >= {_1} và < {_2} +@msg a number > {_1} and <= {_2} một số > {_1} và <= {_2} +@msg a number > {_1} and < {_2} một số > {_1} và < {_2} +@msg a number ({_1}) một số ({_1}) +@msg a number >= {_1} ({_2}) một số >= {_1} ({_2}) +@msg a number > {_1} ({_2}) một số > {_1} ({_2}) +@msg a number <= {_1} ({_2}) một số <= {_1} ({_2}) +@msg a number < {_1} ({_2}) một số < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) một số từ {_1} đến {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) một số >= {_1} và < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) một số > {_1} và <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) một số > {_1} và < {_2} ({_3}) +@msg an integer một số nguyên +@msg an integer >= {_1} một số nguyên >= {_1} +@msg an integer <= {_1} một số nguyên <= {_1} +@msg an integer {_1} to {_2} một số nguyên từ {_1} đến {_2} +@msg one of {_1} một trong các giá trị {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` phải là một trong các giá trị {_1}, không phải {got} +@msg an object id (a non-negative integer) một ID đối tượng (một số nguyên không âm) +@msg a list of object ids một danh sách ID đối tượng +@msg a color (a string, [r, g, b] or an object) một màu (một chuỗi, [r, g, b] hoặc một đối tượng) +@msg [x, y] (two numbers) [x, y] (hai số) +@msg an object một đối tượng +@msg a list một danh sách @msg unknown key `{k}`; the keys here are {_1} khóa không xác định `{k}`; các khóa ở đây là {_1} @msg {_1} is listed twice {_1} được liệt kê hai lần @msg {_1} is not the id of an input object {_1} không phải là id của một đối tượng đầu vào diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d1e4de6bc6..f5415f5d21 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6161,6 +6161,35 @@ axis Y Axis Y 轴 @msg the path has no anchor points 路径没有锚点 @msg too many anchor points (limit {MAX_ANCHORS}) 锚点过多(上限 {MAX_ANCHORS}) @msg true or false true 或 false +@msg a number >= {_1} 大于或等于 {_1} 的数字 +@msg a number > {_1} 大于 {_1} 的数字 +@msg a number <= {_1} 小于或等于 {_1} 的数字 +@msg a number < {_1} 小于 {_1} 的数字 +@msg a number {_1} to {_2} {_1} 到 {_2} 之间的数字 +@msg a number >= {_1} and < {_2} 大于或等于 {_1} 且小于 {_2} 的数字 +@msg a number > {_1} and <= {_2} 大于 {_1} 且小于或等于 {_2} 的数字 +@msg a number > {_1} and < {_2} 大于 {_1} 且小于 {_2} 的数字 +@msg a number ({_1}) 数字({_1}) +@msg a number >= {_1} ({_2}) 大于或等于 {_1} 的数字({_2}) +@msg a number > {_1} ({_2}) 大于 {_1} 的数字({_2}) +@msg a number <= {_1} ({_2}) 小于或等于 {_1} 的数字({_2}) +@msg a number < {_1} ({_2}) 小于 {_1} 的数字({_2}) +@msg a number {_1} to {_2} ({_3}) {_1} 到 {_2} 之间的数字({_3}) +@msg a number >= {_1} and < {_2} ({_3}) 大于或等于 {_1} 且小于 {_2} 的数字({_3}) +@msg a number > {_1} and <= {_2} ({_3}) 大于 {_1} 且小于或等于 {_2} 的数字({_3}) +@msg a number > {_1} and < {_2} ({_3}) 大于 {_1} 且小于 {_2} 的数字({_3}) +@msg an integer 整数 +@msg an integer >= {_1} 大于或等于 {_1} 的整数 +@msg an integer <= {_1} 小于或等于 {_1} 的整数 +@msg an integer {_1} to {_2} {_1} 到 {_2} 之间的整数 +@msg one of {_1} {_1} 之一 +@msg `{path}` must be one of {_1}, not {got} `{path}` 必须是 {_1} 之一,而不是 {got} +@msg an object id (a non-negative integer) 对象 ID(非负整数) +@msg a list of object ids 对象 ID 列表 +@msg a color (a string, [r, g, b] or an object) 颜色(字符串、[r, g, b] 或对象) +@msg [x, y] (two numbers) [x, y](两个数字) +@msg an object 对象 +@msg a list 列表 @msg unknown key `{k}`; the keys here are {_1} 未知键 `{k}`;此处可用的键:{_1} @msg {_1} is listed twice {_1} 列出了两次 @msg {_1} is not the id of an input object {_1} 不是输入对象的 ID diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8e49ed109a..e83c81ef30 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5966,6 +5966,35 @@ axis Y Axis Y 軸 @msg the path has no anchor points 路徑沒有錨點 @msg too many anchor points (limit {MAX_ANCHORS}) 錨點過多(上限 {MAX_ANCHORS}) @msg true or false true 或 false +@msg a number >= {_1} 大於或等於 {_1} 的數字 +@msg a number > {_1} 大於 {_1} 的數字 +@msg a number <= {_1} 小於或等於 {_1} 的數字 +@msg a number < {_1} 小於 {_1} 的數字 +@msg a number {_1} to {_2} 介於 {_1} 到 {_2} 之間的數字 +@msg a number >= {_1} and < {_2} 大於或等於 {_1} 且小於 {_2} 的數字 +@msg a number > {_1} and <= {_2} 大於 {_1} 且小於或等於 {_2} 的數字 +@msg a number > {_1} and < {_2} 大於 {_1} 且小於 {_2} 的數字 +@msg a number ({_1}) 數字({_1}) +@msg a number >= {_1} ({_2}) 大於或等於 {_1} 的數字({_2}) +@msg a number > {_1} ({_2}) 大於 {_1} 的數字({_2}) +@msg a number <= {_1} ({_2}) 小於或等於 {_1} 的數字({_2}) +@msg a number < {_1} ({_2}) 小於 {_1} 的數字({_2}) +@msg a number {_1} to {_2} ({_3}) 介於 {_1} 到 {_2} 之間的數字({_3}) +@msg a number >= {_1} and < {_2} ({_3}) 大於或等於 {_1} 且小於 {_2} 的數字({_3}) +@msg a number > {_1} and <= {_2} ({_3}) 大於 {_1} 且小於或等於 {_2} 的數字({_3}) +@msg a number > {_1} and < {_2} ({_3}) 大於 {_1} 且小於 {_2} 的數字({_3}) +@msg an integer 整數 +@msg an integer >= {_1} 大於或等於 {_1} 的整數 +@msg an integer <= {_1} 小於或等於 {_1} 的整數 +@msg an integer {_1} to {_2} 介於 {_1} 到 {_2} 之間的整數 +@msg one of {_1} {_1} 其中之一 +@msg `{path}` must be one of {_1}, not {got} `{path}` 必須是 {_1} 其中之一,而不是 {got} +@msg an object id (a non-negative integer) 物件 ID(非負整數) +@msg a list of object ids 物件 ID 清單 +@msg a color (a string, [r, g, b] or an object) 顏色(字串、[r, g, b] 或物件) +@msg [x, y] (two numbers) [x, y](兩個數字) +@msg an object 物件 +@msg a list 清單 @msg unknown key `{k}`; the keys here are {_1} 未知的鍵 `{k}`;此處可用的鍵:{_1} @msg {_1} is listed twice {_1} 列出了兩次 @msg {_1} is not the id of an input object {_1} 不是輸入物件的 ID diff --git a/docs/mcp.md b/docs/mcp.md index 2c8afc7aea..5596fd0bf5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1799,6 +1799,11 @@ parameter error, ``invalid parameters for ``: `` is nested too deeply` value. A schema that is wrong in itself, such as one that nests past 16 levels or puts a list of ids or a point inside 16 lists, is an internal error in the command, and the command does not run. +The command API returns these errors in English. The desktop app's status bar shows them in the interface language, +the requirement after "must be" included (a number and its range and unit, an integer, a list of names, an object id, +a color, a point, an object or a list); the parameter's path, the refused value, the bounds, the unit and the names stay +as the English gives them. + An omitted value with a `default` takes it (`rotation` 0 in `view.saved.new`); the journal keeps the params as they were given. Saved steps played from a journal or an action keep a key the schema does not name, unless its value nests more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 3 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1ce5c5fda5..d62ca2f683 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -860,6 +860,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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 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`). + - A parameter schema's errors show in the status bar with what the value must be in the interface language, for every type a schema has (a number with each kind of range and with a unit, an integer and its range, true or false, a string, a list of names, an object id, a list of object ids, a color, a point, an object and a list), in every catalog that translates all status and error messages; the path, the refused value, the bounds, the unit and the names stay as the English gives them. The error for a value that is not one of its names is also translated in Bulgarian, Bosnian, Danish, Indonesian and Dutch (`parameter_schema_expected_phrases_are_translated`, `bracketed_message_parts_translate_only_as_exact_messages`). - 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. - Commands that read their `ids` (or `slices`) list themselves refuse a list with an entry that is not a non-negative integer, and a value that is not a list, and change nothing; they no longer drop the entry and act on the rest (Copy Appearance From, the Edit Colors commands and Overprint Black, Find and Replace, `layer.highlight`, `layer.move`, `library.add`, the Links panel commands, `object.transform`, Make Pattern, the Repeat commands, the slice commands, Join, Average and Join, `plugin.run`, `symbol.edit`, Make Opacity Mask; `tests::ids_without_selection::an_ids_entry_that_is_not_an_id_is_refused_and_changes_nothing`). The error for an entry of an object id list names the key, the entry and its value ("`ids` entry 1 is true, not a non-negative integer object id"), with a row in every catalog that has the other object id errors. From 9dc60090073f335dc641fdb6467ce30718dae4cc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 20:12:14 -0800 Subject: [PATCH 23/68] 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 24/68] 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 f5bfe0c4258607888720955c9ded84318ab52a44 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 20:27:09 -0800 Subject: [PATCH 25/68] Effects: effect.apply refuses a wrongly typed parameter that has no default effect.apply, appearance.addEffect and effect.setParams checked an effect parameter's type only when the effect has a dialog default for the key. Keys that only the params text names were stored as given: an Outline Stroke with `width: "10 pt"` drew at the stroke's own weight, and a Gaussian Blur with `linear: 1` blurred in linear light. - The earlier 3D `perspective`, `lightAzimuth` and `lightElevation`, Scribble's `variation` and Outline Stroke's `width` must be numbers, and Gaussian Blur's `linear` must be true or false. A test walks every effect so a new key without a default needs a listed type or a check of its own. - The `style` of Crop Marks must be `roman` or `japanese`, and `null` is refused. Any other value drew as Roman. - An installed plug-in effect's parameters are checked against its manifest when the command runs, as plugin.run already checks the effect records a filter returns. - effect.setParams with `warpStyle` checks the parameters against the new Warp style's effect, as it already did for a 3D effect's new `threeDType`. It checked them against the old style. - effect.setParams refuses a `visible` that is not true or false, such as `1` or `"false"`, when called and when a step plays, and nothing changes. Before, the value was ignored and the rest of the call applied. `null` still leaves the visibility as it is. A step of command.batch is refused the same way. A replayed journal or action still plays a wrongly typed effect parameter, as it plays a key the effect no longer takes. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/effectcmd.rs | 22 +++++- crates/engine/src/cmd/effectcmd/tests.rs | 44 +++++++++++ crates/engine/src/cmd/params.rs | 83 +++++++++++++++----- crates/engine/src/tests/params.rs | 99 ++++++++++++++++++++++++ crates/engine/src/tests/plugins.rs | 8 ++ docs/mcp.md | 25 +++++- docs/target-app-parity.md | 1 + 7 files changed, 256 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 72a72e6411..3201adfbe6 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -16,7 +16,7 @@ use vectorcraft_render::effects; use super::appearance::{ItemTarget, aligned, appearance_targets, index_param, item_indices, item_target, item_target_at}; use super::expand::has_seeded; use super::rasterfx; -use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, fileio, has_doc, str_param, switch_or}; +use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, check_switches, cmd, fileio, has_doc, str_param, switch_or}; /// The name of the `svg.filter` effect drawing `xml`: its `` id. An effect names the /// document's filter of that name, so `xml` that differs from the document's filter of the same @@ -76,7 +76,7 @@ fn effect_specs() -> Vec { "Effect Options", [], None, - "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}", + "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place; params are checked against the new style), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool (any other value but null is refused, when a step plays too), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}", has_doc, set_params ), @@ -649,15 +649,28 @@ pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec { return vec![]; }; let Ok(targets) = effect_targets(s, p, item, index, C) else { return vec![] }; - // A 3D effect whose type `threeDType` changes takes the new effect's parameters. + // A 3D effect whose type `threeDType` changes, and a Warp whose style `warpStyle` changes, take + // the new effect's parameters. let kind = three_d_type(p, C).ok().flatten(); + let warp = p.get("warpStyle").and_then(Value::as_str).filter(|style| effects::WarpStyle::from_id(style).is_some()); let effect = |&(id, at, index): &(NodeId, Option, usize)| { let id = &st.doc.node(id)?.appearance.effects_at(at)?.get(index)?.id; - Some(kind.filter(|_| effects::is_three_d(id)).map_or_else(|| id.clone(), str::to_string)) + Some(edited_effect_id(id, kind, warp)) }; targets.iter().filter_map(effect).collect() } +/// The id effect `id` has once `effect.setParams` sets its 3D type to `kind` or its Warp style to +/// `warp` (each when given and when `id` is an effect of that family). +fn edited_effect_id(id: &str, kind: Option<&str>, warp: Option<&str>) -> String { + let is_warp = id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_some(); + match (kind, warp) { + (Some(to), _) if effects::is_three_d(id) => to.to_string(), + (_, Some(style)) if is_warp => format!("warp.{style}"), + _ => id.to_string(), + } +} + /// The effect `threeDType` in `p` turns a 3D effect into ([`effects::THREE_D_TYPES`]), when given. fn three_d_type(p: &Value, cmd: &str) -> Result> { let key = "threeDType"; @@ -718,6 +731,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result { Ok(format!("warp.{style}")) }) .transpose()?; + check_switches(p, &["visible"], C)?; let visible = p.get("visible").and_then(Value::as_bool); let item = item_target(s, p, C)?; let edited = set_params_effects(s, p); diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index 5584073563..8b5f90eb4a 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -626,6 +626,50 @@ fn warp_options_non_warp_target_rejection_is_atomic() { assert!(!state.is_dirty()); } +/// `effect.setParams` refuses a `visible` that is not true or false, when called and when a step +/// plays, and the effect, the document and the undo history stay as they were. `null` leaves the +/// visibility as it is. +#[test] +fn set_params_refuses_a_visible_that_is_not_true_or_false() { + let (mut s, id) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": "distort.roughen", "item": null})).unwrap(); + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + for visible in [json!(1), json!(0), json!("false"), json!([])] { + let p = json!({"index": 0, "item": null, "visible": visible, "params": {"size": 9}}); + for replay in [false, true] { + let r = if replay { s.replay("effect.setParams", &p) } else { s.execute("effect.setParams", &p) }; + let err = r.err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err.contains("`visible` must be true or false"), "{visible} (replay {replay}): {err}"); + let state = s.doc().unwrap(); + assert_eq!(state.doc, before, "{visible} (replay {replay})"); + assert_eq!(state.history.undo.len(), undo, "{visible} (replay {replay})"); + assert!(!state.is_dirty()); + } + } + s.execute("effect.setParams", &json!({"index": 0, "item": null, "visible": null, "params": {"size": 9}})).unwrap(); + let n = node(&s, id); + assert!(n.appearance.effects[0].visible); + assert_eq!(n.appearance.effects[0].params["size"], 9); +} + +/// The effect `effect.setParams` checks the parameters against is the one it leaves: a Warp's new +/// style and a 3D effect's new type, and the effect as it is otherwise. +#[test] +fn set_params_checks_the_effect_it_leaves() { + use super::edited_effect_id; + assert_eq!(edited_effect_id("warp.arc", None, Some("flag")), "warp.flag"); + assert_eq!(edited_effect_id("warp.arc", None, None), "warp.arc"); + assert_eq!(edited_effect_id("distort.roughen", None, Some("flag")), "distort.roughen"); + assert_eq!(edited_effect_id("threeD.extrude", Some("threeD.revolve"), None), "threeD.revolve"); + assert_eq!(edited_effect_id("warp.arc", Some("threeD.revolve"), None), "warp.arc"); + let (mut s, _) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": "warp.arc", "item": null})).unwrap(); + let p = json!({"index": 0, "item": null, "warpStyle": "flag", "params": {"bend": 25}}); + assert_eq!(super::set_params_effects(&s, &p), ["warp.flag"]); +} + #[test] fn list_remove_and_set_params() { let (mut s, id) = session_with_rect(); diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 60fa90c32c..c38854ae76 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -15,8 +15,8 @@ //! commands a command runs, the steps of a `command.batch` or an `action.batch` included: a //! misspelled `widht` no longer runs the command with its default. So are the `params` of an //! effect (#1592): `effect.apply` and -//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its -//! default, with a parameter error ([`check_effect_params`]). +//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than the +//! effect reads, with a parameter error ([`check_effect_params`]). //! //! A command with a parameter schema (`CommandSpec::schema`) is also checked against the schema //! before it runs ([`crate::schemas::check`]): the schema refuses a value of the wrong type or out @@ -263,15 +263,15 @@ pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError } /// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the -/// effect takes (those of its dialog defaults and of its params description), each of its -/// default's type, a `color` the effect can draw ([`check_effect_color`]) and a Levels or Curves -/// `channel` the effect has ([`check_effect_channel`]). Numbers out of range pass: the effect -/// clamps them when it draws. An effect plug-in resolves its own against its manifest, and an -/// unknown effect is the command's to report. +/// effect takes (those of its dialog defaults and of its params description), each of the type +/// the effect reads ([`check_effect_types`]), a `color` the effect can draw ([`check_effect_color`]) +/// and a Levels or Curves `channel` the effect has ([`check_effect_channel`]). Numbers out of range +/// pass: the effect clamps them when it draws. An installed effect plug-in's are checked against its +/// manifest, and an unknown effect is the command's to report. pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> { let Value::Object(given) = params else { return Ok(()) }; if effect.starts_with(vectorcraft_plugins::effect::PREFIX) { - return Ok(()); + return check_effect_types(id, effect, params); } let Some(info) = vectorcraft_render::effects::effect_info(effect) else { return Ok(()) }; let defaults = info.defaults.as_object(); @@ -282,18 +282,10 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> keys.push(key); } } - for (key, v) in given { - if !keys.contains(&key.as_str()) { - return Err(unknown(id, key, &keys)); - } - match defaults.and_then(|d| d.get(key)) { - Some(Value::Number(_)) if !v.is_number() => return Err(bad(id, format!("`{key}` must be a number"))), - Some(Value::Bool(_)) if !v.is_boolean() => return Err(bad(id, format!("`{key}` must be true or false"))), - // (A colour is also taken as `[r, g, b]` or as a colour object.) - Some(Value::String(_)) if v.is_number() || v.is_boolean() => return Err(bad(id, format!("`{key}` must be a string"))), - _ => {} - } + if let Some(key) = given.keys().find(|k| !keys.contains(&k.as_str())) { + return Err(unknown(id, key, &keys)); } + check_effect_types(id, effect, params)?; // An SVG filter's XML is one drawable element. if effect == vectorcraft_render::effects::SVG_FILTER && let Some(xml) = given.get("xml") @@ -329,6 +321,59 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> check_effect_channel(id, effect, params) } +/// The type of the values in `params` of effect `effect`, as command `id` was given them: a key +/// the effect has a dialog default for takes a value of its default's type, a key only the +/// effect's params description names takes the type the effect reads ([`undefaulted_type`]), and +/// an installed effect plug-in's take the types its manifest gives them. The `style` of Crop Marks +/// is `roman` or `japanese`. A number given as text (`"10 pt"`), `true` for a number, `1` for a +/// switch and `null` for either are refused. +fn check_effect_types(id: &str, effect: &str, params: &Value) -> Result<()> { + let Value::Object(given) = params else { return Ok(()) }; + if effect.starts_with(vectorcraft_plugins::effect::PREFIX) { + return match vectorcraft_plugins::effect::installed(effect) { + Some(plugin) => plugin.manifest().resolve_params(params).map(drop).map_err(|why| bad(id, why.to_string())), + None => Ok(()), + }; + } + let Some(info) = vectorcraft_render::effects::effect_info(effect) else { return Ok(()) }; + for (key, v) in given { + let want = info.defaults.get(key).cloned().or_else(|| undefaulted_type(effect, key)); + match want { + Some(Value::Number(_)) if !v.is_number() => return Err(bad(id, format!("`{key}` must be a number"))), + Some(Value::Bool(_)) if !v.is_boolean() => return Err(bad(id, format!("`{key}` must be true or false"))), + // (A colour is also taken as `[r, g, b]` or as a colour object.) + Some(Value::String(_)) if v.is_number() || v.is_boolean() => return Err(bad(id, format!("`{key}` must be a string"))), + _ => {} + } + } + // Crop Marks draw one of two styles. + if effect == vectorcraft_render::effects::CROP_MARKS + && let Some(style) = given.get("style") + && style.as_str().and_then(vectorcraft_doc::marks::MarkStyle::parse).is_none() + { + return Err(bad(id, "`style` must be roman or japanese")); + } + Ok(()) +} + +/// A value of the type effect `effect` reads for `key`, a parameter its params description names +/// without a dialog default: the earlier 3D Perspective and light (`perspective`, `lightAzimuth`, +/// `lightElevation`) and Scribble `variation` are numbers, an Outline Stroke's `width` is a number +/// and a Gaussian Blur's `linear` is true or false. A Glass or Texturizer `textureImage`, an SVG +/// filter's `name` and `xml`, a Warp's `version` and the `style` of Crop Marks have checks of their +/// own; they aren't listed. +pub(crate) fn undefaulted_type(effect: &str, key: &str) -> Option { + use vectorcraft_render::effects; + let number = || Some(Value::from(0.0)); + match key { + "perspective" | "lightAzimuth" | "lightElevation" if effects::is_three_d(effect) => number(), + "variation" if effect == "stylize.scribble" => number(), + "width" if effect == "path.outlineStroke" => number(), + "linear" if effect == "blur.gaussian" => Some(Value::Bool(false)), + _ => None, + } +} + /// Can an effect read `v` as its `color`: a string [`Color::from_hex`] reads (`#rrggbb`, `#rgb`, /// `#rrggbbaa`, black, white, red, green, blue), `[r, g, b]` whose first three entries are numbers /// (read on a 0..1 scale) or a color object (`{"model": "rgb", "r": …}`)? The effect draws its diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs index 3cbdb5df9f..e72f607030 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -600,6 +600,105 @@ fn effect_params_are_the_effects_own() { assert!(s.check_params("effect.setParams", &json!({"index": 7, "params": {"blurr": 3}})).is_ok()); } +/// An effect parameter that only the effect's params description names, with no dialog default, +/// is held to the type the effect reads: the earlier 3D Perspective and light, Scribble's +/// `variation` and an Outline Stroke's `width` are numbers, and a Gaussian Blur's `linear` is true +/// or false. The `style` of Crop Marks is `roman` or `japanese`. Every such key of every effect is +/// listed, or has a check of its own. A key left out keeps its default. +#[test] +fn an_effect_parameter_without_a_default_is_held_to_its_type() { + use crate::cmd::params::undefaulted_type; + use vectorcraft_render::effects::effect_catalog; + // Read by checks of their own: a stored texture or a texture to load, an SVG filter, a Warp's + // map and the style of Crop Marks. + let own_check = |effect: &str, key: &str| match key { + "textureImage" | "name" | "xml" => true, + "version" => effect.starts_with("warp."), + "style" => effect == "cropMarks", + _ => false, + }; + let mut unlisted = vec![]; + for e in effect_catalog() { + for key in doc_keys(e.params).unwrap().keys { + if e.defaults.get(key).is_none() && undefaulted_type(e.id, key).is_none() && !own_check(e.id, key) { + unlisted.push(format!("{}.{key}", e.id)); + } + } + } + assert!(unlisted.is_empty(), "parameters without a default or a listed type: {unlisted:?}"); + let apply = |effect: &str, params| json!({"effect": effect, "params": params}); + for (effect, params, why) in [ + ("threeD.revolve", json!({"perspective": true}), "`perspective` must be a number"), + ("threeD.extrude", json!({"lightAzimuth": "30"}), "`lightAzimuth` must be a number"), + ("threeD.rotate", json!({"lightElevation": null}), "`lightElevation` must be a number"), + ("threeD.inflate", json!({"perspective": "50%"}), "`perspective` must be a number"), + ("path.outlineStroke", json!({"width": "10 pt"}), "`width` must be a number"), + ("stylize.scribble", json!({"variation": false}), "`variation` must be a number"), + ("blur.gaussian", json!({"radius": 2, "linear": 1}), "`linear` must be true or false"), + ("cropMarks", json!({"style": "bogus"}), "`style` must be roman or japanese"), + ("cropMarks", json!({"style": 1}), "`style` must be roman or japanese"), + ("cropMarks", json!({"style": null}), "`style` must be roman or japanese"), + ] { + assert_eq!(refusal("effect.apply", apply(effect, params.clone())), why, "{effect} {params}"); + assert_eq!(refusal("appearance.addEffect", apply(effect, params.clone())), why, "{effect} {params}"); + } + for (effect, params) in [ + ("threeD.revolve", json!({"perspective": 40, "lightAzimuth": 30.5, "lightElevation": 0})), + ("path.outlineStroke", json!({"width": 10})), + ("stylize.scribble", json!({"variation": 2})), + ("blur.gaussian", json!({"linear": true})), + ("blur.gaussian", json!({})), + ("cropMarks", json!({"style": "japanese"})), + ("cropMarks", json!({})), + ] { + assert!(check_params("effect.apply", &apply(effect, params.clone())).is_ok(), "{effect} {params}"); + } +} + +/// `effect.apply`, `appearance.addEffect` and `effect.setParams` refuse such a parameter of the +/// wrong type when they run, and so does a step of a `command.batch` whose effect a step before it +/// added, with the document and its undo history as they were. A saved step still plays with it, +/// as with a key the effect no longer takes. +#[test] +fn effect_commands_refuse_an_undefaulted_parameter_of_the_wrong_type() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + let state = |s: &Session| { + let st = s.doc().unwrap(); + (st.history.undo.len(), st.doc.clone()) + }; + let before = state(&s); + let apply = |effect: &str, params| json!({"effect": effect, "params": params}); + for (id, params, why) in [ + ("effect.apply", apply("path.outlineStroke", json!({"width": "10 pt"})), "`width` must be a number"), + ("appearance.addEffect", apply("blur.gaussian", json!({"linear": 1})), "`linear` must be true or false"), + ("effect.apply", apply("threeD.revolve", json!({"perspective": "50"})), "`perspective` must be a number"), + ] { + let e = s.execute(id, ¶ms).unwrap_err().to_string(); + assert!(e.contains(why), "{id} {params}: {e}"); + assert_eq!(state(&s), before, "{id} {params}"); + } + let batch = json!({"commands": [ + {"command": "effect.apply", "params": apply("path.outlineStroke", json!({"width": 3}))}, + {"command": "effect.setParams", "params": {"index": 0, "params": {"width": "10 pt"}}}, + ]}); + let e = s.execute("command.batch", &batch).unwrap_err().to_string(); + assert!(e.contains("`width` must be a number"), "{e}"); + assert_eq!(state(&s), before); + s.execute("effect.apply", &apply("path.outlineStroke", json!({"width": 3}))).unwrap(); + let applied = state(&s); + for params in [json!({"width": "10 pt"}), json!({"width": null}), json!({"width": true})] { + let e = s.execute("effect.setParams", &json!({"index": 0, "params": params.clone()})).unwrap_err().to_string(); + assert!(e.contains("`width` must be a number"), "{params}: {e}"); + assert_eq!(state(&s), applied, "{params}"); + } + s.execute("effect.setParams", &json!({"index": 0, "params": {"width": 4}})).unwrap(); + s.replay("effect.setParams", &json!({"index": 0, "params": {"retired": "x", "width": "10 pt"}})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.doc.layers[0].children().unwrap()[0].appearance.effects[0].params["width"], json!("10 pt")); +} + /// The Transform effect (`distort.transform`) is held to its params description: `effect.apply` /// and `effect.setParams` refuse each of its on/off options given as text, such as `reflectX: /// "false"`, and each of its numbers given as text, such as `copies: "0"`, and leave the effect diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 0224d59b38..cc4460d250 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -235,8 +235,16 @@ fn live_effect_plugins_reshape_editable_art_and_expand() { run(&mut s, "select.all", json!({})); run(&mut s, "object.move", json!({"dx": 50, "dy": 0})); assert_eq!(effected(&s), *node(&s, a).path_data().unwrap()); + // A parameter of another type than the manifest gives it is refused. + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let wrong = json!({"ids": [a.0], "effect": "plugin.test.engine.tri", "params": {"size": "4 pt"}}); + let e = s.execute("effect.apply", &wrong).unwrap_err().to_string(); + assert!(e.contains("`size` must be a number"), "{e}"); + assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()), before); run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.tri", "params": {"size": 4}})); assert_eq!(effected(&s), triangle()); + let e = s.execute("effect.setParams", &json!({"ids": [a.0], "index": 1, "params": {"size": true}})).unwrap_err().to_string(); + assert!(e.contains("`size` must be a number"), "{e}"); assert_eq!(node(&s, a).appearance.effects[1].params, json!({"size": 4})); // Uninstalled: the geometry is drawn as it is. run(&mut s, "plugin.remove", json!({"id": "test.engine.tri"})); diff --git a/docs/mcp.md b/docs/mcp.md index 53a792d5a6..840aa9c7ee 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1483,14 +1483,33 @@ refused before the command runs: Only the top level is checked, except for the export and save commands below: the keys of an object inside a value (`artboardLayout: {…}`) are the command's to read. Another exception is an effect's `params`: `effect.apply` (and `appearance.addEffect`) and `effect.setParams` check them against the effect's own entry in `effect.list`, its `defaults` and the keys its `params` text names. A key -the effect doesn't take, or a value of another type than its default (a string for a number, a number for a switch), -is refused the same way: +the effect doesn't take, or a value of another type than the effect reads (a string for a number, a number for a +switch), is refused the same way: ```json {"id": "effect.apply", "params": {"effect": "blur.gaussian", "params": {"blurr": 3}}} -→ invalid parameters for `effect.apply`: unknown parameter `blurr`; the command takes: radius +→ invalid parameters for `effect.apply`: unknown parameter `blurr`; the command takes: radius, linear ``` +A key the effect has a default for takes a value of its default's type. A key only the `params` text names takes the +type the effect reads: the earlier 3D `perspective`, `lightAzimuth` and `lightElevation` and Scribble's `variation` are +numbers, an Outline Stroke's `width` is a number and a Gaussian Blur's `linear` is true or false. A number written as +text (`"10 pt"`), `true` for a number, `1` for a switch and `null` for either are refused; a key left out keeps its +default. The `style` of Crop Marks must be `roman` or `japanese`. An installed plug-in effect's parameters take the +types its manifest gives them. A step of `command.batch` gets the same error, and the document and its undo history +stay as they were: + +```json +{"id": "effect.apply", "params": {"effect": "path.outlineStroke", "params": {"width": "10 pt"}}} +→ invalid parameters for `effect.apply`: `width` must be a number +``` + +A replayed journal or action still plays such a value, as it plays a key the effect no longer takes. + +`effect.setParams` with `warpStyle` checks `params` against the new Warp style, and with `threeDType` against the new +3D effect. Its own `visible` must be true or false (`null` leaves the visibility as it is); any other value is refused, +in a replayed journal or action too, and nothing changes. + The `color` of Drop Shadow, Inner Glow, Outer Glow and Shift to Color must be one the effect can read: a hex color (`#rrggbb`, `#rgb` or `#rrggbbaa`, with or without the `#`), `black`, `white`, `red`, `green` or `blue`, `[r, g, b]` (three numbers, each read on a 0 to 1 scale), or a color object (`{"model": "rgb", "r": 1, "g": 0, "b": 0}`). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ed2e6e180..0700fbca53 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -793,6 +793,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Plug-in, recovery and script report replies under `--automation-read-root`: `plugin.list`, `plugin.info` and `plugin.safeMode` name the plug-in folder, failed files, plug-in sources and disabled plug-ins' files outside the read roots by file name or `null`, `file.recovery.*` leave out a document's original file and the recovery folder outside them, and `ui.inspect`'s `scriptReport` names files a run wrote outside them by file name. `crates/engine/src/tests/plugins.rs` (`plugin_list_leaves_out_paths_outside_the_read_root`), `crates/engine/src/tests/recovery.rs` (`recovery_replies_leave_out_paths_outside_the_read_root`), `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_script_report_leaves_out_files_outside_the_read_root`). - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. + - Effect parameter types: `effect.apply`, `appearance.addEffect` and `effect.setParams` refuse an effect parameter of another type than the effect reads, for keys only the params text names too (the earlier 3D `perspective`, `lightAzimuth` and `lightElevation`, Scribble's `variation`, Outline Stroke's `width`, Gaussian Blur's `linear`), a Crop Marks `style` other than `roman` or `japanese`, and an installed plug-in effect's parameter of another type than its manifest gives it, in a `command.batch` step too, with the document and undo history unchanged; a replayed action still plays. `effect.setParams` checks the parameters against a Warp's new style, and refuses a `visible` that is not true or false, in a replayed action too. `crates/engine/src/tests/params.rs` (`an_effect_parameter_without_a_default_is_held_to_its_type`, `effect_commands_refuse_an_undefaulted_parameter_of_the_wrong_type`), `crates/engine/src/tests/plugins.rs` (`live_effect_plugins_reshape_editable_art_and_expand`), `crates/engine/src/cmd/effectcmd/tests.rs` (`set_params_refuses_a_visible_that_is_not_true_or_false`, `set_params_checks_the_effect_it_leaves`). - MCP server with 33 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, undo/redo, UI inspect/screenshot aliases, any command), attached to the running app or headless. Headless and remote `command_list` include desktop `UI_COMMANDS` (`ui: true`); a UI-only id still needs `--connect` to run. Eight prompts (poster, icon-set, recolor, trace-and-style, export-set, check-spelling, variables-bind, path-tools). Effect ids are not command ids. Prefer a CLI built from the checkout when a release binary lags (#6460). `vectorcraft://commands/reference` serves the API reference. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). From fad257ce8277a6a7315f2ce75f612607352e30dc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 20:27:27 -0800 Subject: [PATCH 26/68] Drawing tools: the measurement readout shows with the rubber band off Enable Rubber Band now hides only the preview segment in the Pen, the Curvature tool and the Smart Pen. With it off and Measurement Labels on, the three tools still show the D / Angle / dX / dY readout at the pointer while a path is drawn, and the Curvature tool also shows it when going on from the current end of a selected path. With Measurement Labels off, no readout shows either way. The click places the same anchor as before. Docs: docs/mcp.md (penRubberBand, curvatureRubberBand), docs/ui-parity.md, docs/target-app-parity.md. --- crates/tools/src/addons/smart_pen.rs | 11 +++-- crates/tools/src/addons/smart_pen/tests.rs | 29 +++++++++++++ crates/tools/src/draw2/curvature.rs | 40 ++++++++++-------- crates/tools/src/draw2/curvature/tests.rs | 48 ++++++++++++++++++++++ crates/tools/src/pen.rs | 20 +++++---- crates/tools/src/pen/tests.rs | 42 +++++++++++++++++++ docs/mcp.md | 3 +- docs/target-app-parity.md | 1 + docs/ui-parity.md | 2 +- 9 files changed, 164 insertions(+), 32 deletions(-) diff --git a/crates/tools/src/addons/smart_pen.rs b/crates/tools/src/addons/smart_pen.rs index 6a287b9b54..cd72f1dd40 100644 --- a/crates/tools/src/addons/smart_pen.rs +++ b/crates/tools/src/addons/smart_pen.rs @@ -573,12 +573,15 @@ impl Tool for SmartPenTool { fn overlays(&self, cx: &ToolContext) -> Vec { let mut o = vec![]; - if cx.pen_rubber_band - && self.press.is_none() + // The rubber band preference hides only the preview segment; Measurement Labels decides the readout. + if self.press.is_none() && let (Some((id, _, sp)), Some(h)) = (self.active(cx), self.hover) - && let Some(path) = self.rubber_band(sp, h) { - o.push(Overlay::Path { path, color: cx.doc.layer_color(id), width: 1.0, dashed: false }); + if cx.pen_rubber_band + && let Some(path) = self.rubber_band(sp, h) + { + o.push(Overlay::Path { path, color: cx.doc.layer_color(id), width: 1.0, dashed: false }); + } if let Some(last) = sp.anchors.last().map(|a| a.p) && cx.measurement_labels && last.distance(h) > 1e-9 diff --git a/crates/tools/src/addons/smart_pen/tests.rs b/crates/tools/src/addons/smart_pen/tests.rs index a7f792c22f..8bcc907f9d 100644 --- a/crates/tools/src/addons/smart_pen/tests.rs +++ b/crates/tools/src/addons/smart_pen/tests.rs @@ -315,6 +315,35 @@ fn the_rubber_band_shows_the_next_segment_and_ending_the_path() { assert!(t.overlays(&cx).is_empty()); } +/// With Enable Rubber Band for the Pen off, the preview segment is hidden but Measurement Labels +/// still shows the readout from the last anchor to the pointer. +#[test] +fn the_measurement_label_shows_with_the_rubber_band_off() { + let (d, _, s) = doc(line()); + let p = paint(); + let on = plain(&d, &s, &p); + let off = ToolContext { pen_rubber_band: false, ..plain(&d, &s, &p) }; + let (from, to) = (Point::new(200.0, 100.0), Point::new(300.0, 160.0)); + let measure = + |o: &[Overlay]| o.iter().filter_map(|o| if let Overlay::Measure { text, .. } = o { Some(text.clone()) } else { None }).collect::>(); + let band = |o: &[Overlay]| o.iter().any(|o| matches!(o, Overlay::Path { .. })); + let mut t = drawing(&off, from); + t.pointer(&off, &ev(PointerKind::Move, to.x, to.y, NO)); + let o = t.overlays(&off); + assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}"); + assert!(!band(&o), "{o:?}"); + let o = t.overlays(&on); + assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}"); + assert!(band(&o), "{o:?}"); + for rubber in [true, false] { + let quiet = ToolContext { pen_rubber_band: rubber, measurement_labels: false, ..plain(&d, &s, &p) }; + assert!(measure(&t.overlays(&quiet)).is_empty()); + } + // Hovering exactly on the last anchor measures no segment. + t.pointer(&off, &ev(PointerKind::Move, from.x, from.y, NO)); + assert!(measure(&t.overlays(&off)).is_empty()); +} + #[test] fn an_alt_drag_away_from_paths_selects_the_points_inside_a_marquee() { let (d, id, s) = doc(SubPath::polyline(&[Point::new(100.0, 100.0), Point::new(200.0, 100.0), Point::new(300.0, 100.0)], false)); diff --git a/crates/tools/src/draw2/curvature.rs b/crates/tools/src/draw2/curvature.rs index c40607ee63..02087d997a 100644 --- a/crates/tools/src/draw2/curvature.rs +++ b/crates/tools/src/draw2/curvature.rs @@ -718,38 +718,44 @@ impl Tool for CurvatureTool { } fn overlays(&self, cx: &ToolContext) -> Vec { let mut o = self.snap.guides().to_vec(); - let band = cx.curvature_rubber_band && self.drag.is_none(); + // The rubber band preference hides only the preview curve; Measurement Labels decides the readout. + let idle = self.drag.is_none(); + let band = cx.curvature_rubber_band; let mut bp = BezPath::new(); if self.active(cx).is_none() { // The curve on from the current end of a selected path: its last segment and the next. - if band + if idle && let (Some(e), Some(h)) = (current_end(cx), self.hover) && let Some(sp) = subpath(cx, e.id, e.si) { - let n = sp.anchors.len(); - let near = if e.start { - sp.anchors.iter().take(2).rev().map(Anchor::reversed).collect() - } else { - sp.anchors.iter().skip(n.saturating_sub(2)).copied().collect() - }; - let mut tail = SubPath::new(near, false); - curvature_extend(&mut tail, false, h, self.how(&e), false); - tail.to_bezpath_into(&mut bp); - o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false }); + if band { + let n = sp.anchors.len(); + let near = if e.start { + sp.anchors.iter().take(2).rev().map(Anchor::reversed).collect() + } else { + sp.anchors.iter().skip(n.saturating_sub(2)).copied().collect() + }; + let mut tail = SubPath::new(near, false); + curvature_extend(&mut tail, false, h, self.how(&e), false); + tail.to_bezpath_into(&mut bp); + o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false }); + } if cx.measurement_labels && e.p.distance(h) > 1e-9 { o.push(Overlay::Measure { p: h + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: cx.segment_measure_label(e.p, h) }); } } return o; } - if band + if idle && !self.closed && let Some(h) = self.hover { - let mut pts = self.pts.clone(); - pts.push((h, false)); - curvature_spline(&pts, false).to_bezpath_into(&mut bp); - o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false }); + if band { + let mut pts = self.pts.clone(); + pts.push((h, false)); + curvature_spline(&pts, false).to_bezpath_into(&mut bp); + o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false }); + } if let Some((from, _)) = self.pts.last() && cx.measurement_labels && from.distance(h) > 1e-9 diff --git a/crates/tools/src/draw2/curvature/tests.rs b/crates/tools/src/draw2/curvature/tests.rs index f21deec016..50ef2281d1 100644 --- a/crates/tools/src/draw2/curvature/tests.rs +++ b/crates/tools/src/draw2/curvature/tests.rs @@ -539,3 +539,51 @@ fn clicks_go_on_from_a_current_end_and_the_other_end_closes() { let a = press(&mut t, &quiet(&d, &s, &p), -50.0, 150.0, false); assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["end"] == "start" && v["from"] == "keep"), "{a:?}"); } + +/// With Enable Rubber Band for the Curvature tool off, the preview curve is hidden but +/// Measurement Labels still shows the readout, both while drawing a path and going on from the +/// current end of a selected one. +#[test] +fn the_measurement_label_shows_with_the_rubber_band_off() { + let p = paint(); + let measure = + |o: &[Overlay]| o.iter().filter_map(|o| if let Overlay::Measure { text, .. } = o { Some(text.clone()) } else { None }).collect::>(); + let band = |o: &[Overlay]| o.iter().any(|o| matches!(o, Overlay::Path { .. })); + let to = pt(650.0, 60.0); + // Going on from the current end of a selected path. + let (d, id) = doc_with(wave()); + let s = selected(id, Some(4)); + let on = quiet(&d, &s, &p); + let off = ToolContext { curvature_rubber_band: false, ..quiet(&d, &s, &p) }; + let mut t = CurvatureTool::default(); + t.pointer(&off, &PointerEvent::new(PointerKind::Move, to.x, to.y)); + let from = pt(450.0, 60.0); + let o = t.overlays(&off); + assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}"); + assert!(!band(&o), "{o:?}"); + let o = t.overlays(&on); + assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}"); + assert!(band(&o), "{o:?}"); + // A path being drawn. + let (d, id) = doc_with(SubPath::polyline(&[pt(100.0, 100.0), pt(200.0, 150.0)], false)); + let s = selected(id, None); + let on = quiet(&d, &s, &p); + let off = ToolContext { curvature_rubber_band: false, ..quiet(&d, &s, &p) }; + let mut t = CurvatureTool { id: Some(id), pts: vec![(pt(100.0, 100.0), false), (pt(200.0, 150.0), false)], ..CurvatureTool::default() }; + assert!(t.active(&off).is_some()); + t.pointer(&off, &PointerEvent::new(PointerKind::Move, to.x, to.y)); + let from = pt(200.0, 150.0); + let o = t.overlays(&off); + assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}"); + assert!(!band(&o), "{o:?}"); + let o = t.overlays(&on); + assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}"); + assert!(band(&o), "{o:?}"); + for rubber in [true, false] { + let unlabeled = ToolContext { curvature_rubber_band: rubber, measurement_labels: false, ..quiet(&d, &s, &p) }; + assert!(measure(&t.overlays(&unlabeled)).is_empty()); + } + // Hovering exactly on the last point measures no segment. + t.pointer(&off, &PointerEvent::new(PointerKind::Move, from.x, from.y)); + assert!(measure(&t.overlays(&off)).is_empty()); +} diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 0b625f2813..a0ab57ae87 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -495,19 +495,21 @@ impl Tool for PenTool { return t.overlays(cx); } let mut o = vec![]; - if cx.pen_rubber_band - && self.drag.is_none() + // The rubber band preference hides only the preview segment; Measurement Labels decides the readout. + if self.drag.is_none() && self.handle.is_none() && let (Some((id, _, _, last, out)), Some(h)) = (self.active(cx), self.hover) { - let mut bp = BezPath::new(); - bp.move_to(last); - if out.distance(last) > 1e-9 { - bp.quad_to(out, h); - } else { - bp.line_to(h); + if cx.pen_rubber_band { + let mut bp = BezPath::new(); + bp.move_to(last); + if out.distance(last) > 1e-9 { + bp.quad_to(out, h); + } else { + bp.line_to(h); + } + o.push(Overlay::Path { path: bp, color: cx.doc.layer_color(id), width: 1.0, dashed: false }); } - o.push(Overlay::Path { path: bp, color: cx.doc.layer_color(id), width: 1.0, dashed: false }); if cx.measurement_labels && last.distance(h) > 1e-9 { o.push(Overlay::Measure { p: h + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: cx.segment_measure_label(last, h) }); } diff --git a/crates/tools/src/pen/tests.rs b/crates/tools/src/pen/tests.rs index 478589fe9e..32c91318e6 100644 --- a/crates/tools/src/pen/tests.rs +++ b/crates/tools/src/pen/tests.rs @@ -773,6 +773,48 @@ fn anchors_snap_to_smart_guides() { assert_eq!(a[1], Action::Preview("path.create".into(), json!({"anchors": [{"x": 100.0, "y": 100.0}]}))); } +/// With Enable Rubber Band off, the preview segment is hidden but Measurement Labels still shows +/// the D / Angle / dX / dY readout at the snapped point before the next click. +#[test] +fn measurement_label_shows_with_the_rubber_band_off() { + let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false)); + let p = paint(); + let on = cx(&d, &s, &p); + let off = ToolContext { pen_rubber_band: false, ..cx(&d, &s, &p) }; + let from = Point::new(60.0, 300.0); + let to = Point::new(150.0, 318.0); + let measure = + |o: &[Overlay]| o.iter().filter_map(|o| if let Overlay::Measure { text, .. } = o { Some(text.clone()) } else { None }).collect::>(); + let preview = |o: &[Overlay]| { + o.iter().any(|o| matches!(o, Overlay::Path { path, .. } if path.elements().first().and_then(|e| e.end_point()) == Some(from))) + }; + let mut t = PenTool { drawing: true, ..PenTool::default() }; + t.pointer(&off, &PointerEvent::new(PointerKind::Move, 152.0, 318.0)); + let o = t.overlays(&off); + assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}"); + assert!(!preview(&o), "{o:?}"); + // The rubber band on still shows both. + let o = t.overlays(&on); + assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}"); + assert!(preview(&o), "{o:?}"); + // Measurement Labels off shows no readout either way. + for band in [true, false] { + let quiet = ToolContext { pen_rubber_band: band, measurement_labels: false, ..cx(&d, &s, &p) }; + assert!(measure(&t.overlays(&quiet)).is_empty()); + } + // The click places the same anchor as with the rubber band on. + let a = click(&mut t, &off, 152.0, 318.0, Mods::default()); + assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 318.0, "subpath": 0}))); + // Hovering exactly on the last anchor measures no segment. + let mut t = PenTool { drawing: true, ..PenTool::default() }; + t.pointer(&off, &PointerEvent::new(PointerKind::Move, 60.0, 300.0)); + assert!(measure(&t.overlays(&off)).is_empty()); + // With no path being drawn there is nothing to measure from. + let mut t = PenTool::default(); + t.pointer(&off, &PointerEvent::new(PointerKind::Move, 152.0, 318.0)); + assert!(measure(&t.overlays(&off)).is_empty()); +} + #[test] fn alt_over_a_shown_handle_or_an_anchor_converts() { // The middle anchor is smooth: handles at (150, 100) and (250, 100) round (200, 100). diff --git a/docs/mcp.md b/docs/mcp.md index 53a792d5a6..f4e699620d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5389,7 +5389,8 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture scales it too; off (the default) it stays where it is. `artboard.duplicate`, `artboard.copy` and `artboard.cut` copy or cut locked and hidden art only with it on. A `lockedAndHidden` param on any of these commands takes its place. - `penRubberBand`, `curvatureRubberBand` (on by default): off, the Pen and Curvature tools draw no preview segment to - the pointer. + the pointer. With either off, the Pen, the Smart Pen (`penRubberBand`) and the Curvature tool (`curvatureRubberBand`) + still show the D / Angle / dX / dY readout at the pointer while `measurementLabels` is on. - `showHandlesMultipleAnchors` (on by default): off, Direct Selection (and the Anchor Point tool, and the Pen with Alt) shows and drags direction handles only while a single anchor is selected. On, the handles of the direct-selected anchors, or of every anchor of a path selected as a whole, show and drag. `handleStyle` (`solid`, `hollow`, `large`) draws their ends (desktop app). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ed2e6e180..55d21c00c1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -892,6 +892,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Four brightness themes, persistent preferences. - **Snapping quick access (#6164):** Control bar › Snapping includes Snap to Glyph (`view.snapToGlyph`) and Alignment Guides (`prefs.set`, `alignmentGuides`). The headless pointer/accessibility test `the_control_bar_snapping_menu_shares_glyph_and_alignment_settings` covers both commands, independent state, checked states after reopening, and all eight menu rows within a 1200 × 400-point window. Floating-panel parity remains unverified; scores and estimates unchanged. - **Measure tools and Snap to Glyph:** with Snap to Glyph, Smart Guides and Alignment Guides on, and Snap to Grid and Snap to Pixel off, both ends of a Measure or Measure & Inspect drag land within the snapping tolerance on the guides of live type that the Character panel's options allow, except Anchor Point, and a Shift-drag's end slides along its 45° step to them. The guide shows during the drag. This is VectorCraft's own rule (`Targets::for_measure`, `xform::measure::tests::its_ends_land_on_glyph_guides_with_snap_to_glyph`); scores and estimates unchanged. + - **Drawing readout without the rubber band:** with Enable Rubber Band for the Pen or Curvature tool off and Measurement Labels on, the Pen, the Smart Pen and the Curvature tool hide the preview segment but still show the D / Angle / dX / dY readout at the snapped pointer before the next click, and the Curvature tool also shows it when going on from the current end of a selected path. Measurement Labels show distance and placement while drawing ([the help page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/smart-guides-options.html)), and the rubber band preference turns off the path preview ([the help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/preview-paths-drawn.html)). Showing the readout with the rubber band off is VectorCraft's own rule (`pen::tests::measurement_label_shows_with_the_rubber_band_off`, and `the_measurement_label_shows_with_the_rubber_band_off` in `draw2::curvature::tests` and `addons::smart_pen::tests`); scores and estimates unchanged. - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Type › Highlight Alternate Glyphs (off by default: on, pink behind OpenType substitutions on screen — ligatures, contextual forms; Document Setup's Highlight Substituted Glyphs stays separate; `prefs.set`, #394). About 14 stored options still do nothing yet (the rest of #394: Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Touch Workspace). - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Type › Highlight Alternate Glyphs (on by default: pink behind OpenType substitutions on screen — ligatures, contextual forms; Document Setup's Highlight Substituted Glyphs stays separate; `prefs.set`, #394). About 14 stored options still do nothing yet (the rest of #394: Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Touch Workspace). - The desktop app keeps the Color panel's Show Options or Hide Options between sessions, and `ui.colorPanel` sets its mode and options as its panel menu does. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5f10ea4841..bc9e1a3244 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -233,7 +233,7 @@ tools, never checked side by side) isn't captured row by row. | "intersect" label and snapping to path intersections | partial | #1658 (part of #1602): a point drawn with the Pen, the shape tools or the other drawing tools and an anchor dragged with Direct Selection snap to where paths and ruler guides cross; a moved selection's own anchors and the Width tool don't | | Construction guides at preference angles while drawing | done | #506 | | Line extension: the dragged end of a straight segment snaps to the segment's own direction at any angle, past its other end or short of it, with the guide along it | done | #2120; Direct Selection and Curvature, one anchor dragged (with several, the move has no single direction); nearer the horizontal or vertical through the other end, the point lines up with it ("align"), so a nearly level segment can still be straightened | -| Measurement labels while drawing, moving and resizing | done | sizes and offsets; the segment being drawn is labeled with its distance, angle and offsets by the Line Segment tool, and by the Pen, Curvature and Smart Pen tools with Enable Rubber Band on (#2983) | +| Measurement labels while drawing, moving and resizing | done | sizes and offsets; the segment being drawn is labeled with its distance, angle and offsets by the Line Segment tool, and by the Pen, Curvature and Smart Pen tools with Enable Rubber Band on or off (#2983) | | Spacing guides (equal distances) | partial | for moves and bounding-box resizes (#394); not while drawing | | Distance Guides: Alt/Option over another object, or an empty part of an artboard, shows the selection's distances to it (Smart Guides › Invoke Distance Guides) | done | Selection tool hover with Smart Guides on: the gap between facing edges, else the offsets of matching edges, to another object; the left, right, top and bottom distances to the artboard; labelled in the General unit. The selection is measured as a whole. Nothing while moving or nudging, which the public pages don't describe. [Use Distance Guides](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-distance-guides-for-accurate-placement.html), [Smart Guides options](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/smart-guides-options.html) | | Snapping tolerance preference | done | | From 1fe4529248b3812cea0e8d22a4c97d3cc91d365b Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:28:16 -0500 Subject: [PATCH 27/68] Roadmap M3 row: the panel-list notes go in the Status cell (#2992, #1638) --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 6c5f1e3ecb..dc64c58717 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 | Graphic Styles list fills 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 Graphic Styles list fills 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 | From b8877455be07f84c653f6be24977ba0b21d0643c Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:28:36 -0500 Subject: [PATCH 28/68] Roadmap M3 row: separate the moved notes from the status text --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index dc64c58717..039bc318e1 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 Graphic Styles list fills 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; Graphic Styles list fills 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 | 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 29/68] 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 | From a0e468071039c2c64fd820ae6570a8be48579cf7 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:29:02 -0500 Subject: [PATCH 30/68] Roadmap M4 row: the #6971 note goes in the Status cell --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 49794edaa1..b8168d070c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -150,7 +150,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 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 | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs (feature-valued alternates and unencoded glyphs applied in one Undo step while keeping Arabic joining, #5514), OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | From 8987d42c19eb92c92adde93f599181005bc16ecf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 20:33:16 -0800 Subject: [PATCH 31/68] i18n: messages with escaped JSON braces match their catalog rows Catalog rows now read `{{` and `}}` as a literal `{` and `}`, in the English source and in the translation, as in a Rust format string. A `{` with no `}` after it is literal text instead of dropping the row. - The plugin.run errors for an importer, an exporter and a live effect (`document.open {"importer": ...}` and the like) now show translated with the plug-in's id and JSON kept, in the 22 catalogs that list them. Their rows were keyed as `{{"importer"}"}}`, which never matched. - The test that keys a format string keeps `{{`/`}}` and numbers only real placeholders, so its expected keys match the runtime text. Two junk rows it produced for the RTF error (hi, sv) are removed. --- crates/ui-egui/src/i18n.rs | 14 ++++- crates/ui-egui/src/i18n/ca.tsv | 4 +- crates/ui-egui/src/i18n/catalog.rs | 41 +++++++++++--- crates/ui-egui/src/i18n/cs.tsv | 4 +- crates/ui-egui/src/i18n/de.tsv | 4 +- crates/ui-egui/src/i18n/el.tsv | 4 +- crates/ui-egui/src/i18n/es.tsv | 4 +- crates/ui-egui/src/i18n/fi.tsv | 4 +- crates/ui-egui/src/i18n/fr.tsv | 4 +- crates/ui-egui/src/i18n/hi.tsv | 7 +-- crates/ui-egui/src/i18n/hu.tsv | 4 +- crates/ui-egui/src/i18n/it.tsv | 4 +- crates/ui-egui/src/i18n/ja.tsv | 4 +- crates/ui-egui/src/i18n/ka.tsv | 4 +- crates/ui-egui/src/i18n/ko.tsv | 4 +- crates/ui-egui/src/i18n/pl.tsv | 4 +- crates/ui-egui/src/i18n/pt-br.tsv | 4 +- crates/ui-egui/src/i18n/ru.tsv | 4 +- crates/ui-egui/src/i18n/sv.tsv | 7 +-- crates/ui-egui/src/i18n/tests.rs | 88 ++++++++++++++++++++++++++++- crates/ui-egui/src/i18n/tr.tsv | 4 +- crates/ui-egui/src/i18n/uk.tsv | 4 +- crates/ui-egui/src/i18n/vi.tsv | 4 +- crates/ui-egui/src/i18n/zh-hans.tsv | 4 +- crates/ui-egui/src/i18n/zh-hant.tsv | 4 +- 25 files changed, 196 insertions(+), 41 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 7ee09ee14e..eeb87a3318 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -677,13 +677,23 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String { } /// Fill `{name}` placeholders in one pass: a value that holds `{name}` text stays as it is. -/// Unknown placeholders are left as written. +/// Unknown placeholders are left as written. `{{` and `}}` are a literal `{` and `}`. fn fill_once(template: &str, args: &[(&str, &str)]) -> String { let mut rest = template; let mut out = String::with_capacity(rest.len()); - while let Some(open) = rest.find('{') { + while let Some(open) = rest.find(['{', '}']) { out.push_str(&rest[..open]); let after = &rest[open + 1..]; + if rest[open..].starts_with('}') { + out.push('}'); + rest = after.strip_prefix('}').unwrap_or(after); + continue; + } + if let Some(escaped) = after.strip_prefix('{') { + out.push('{'); + rest = escaped; + continue; + } let filled = after.find('}').and_then(|close| args.iter().find(|(k, _)| *k == &after[..close]).map(|(_, v)| (*v, close))); match filled { Some((value, close)) => { diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 0399e6c04a..4e17e5bc34 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -7135,7 +7135,9 @@ breakText Glyphs Glifs @msg “{shown}” opens {_1} files, not `{_2}` «{shown}» obre fitxers {_1}, no `{_2}` @msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` és un complement del tipus `{_2}`, no un importador @msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` és un complement del tipus `{_2}`, no un exportador -@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` és un importador: obriu un fitxer amb document.open {{"importer"}"}} +@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` és un importador: obriu un fitxer amb document.open {{"importer": "{_2}"}} +@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` és un exportador: exporteu amb document.export {{"format": "{_2}"}} +@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` és un efecte dinàmic: apliqueu-lo amb effect.apply {{"effect": "{_2}"}} @msg the importer returned no objects l’importador no ha retornat cap objecte @msg an importer returns `objects` and `warnings`, not `{key}` un importador retorna `objects` i `warnings`, no `{key}` @msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads el fitxer supera els {MAX_INPUT_BYTES} bytes que llegeix un importador diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs index 138299643d..ac659ac4b2 100644 --- a/crates/ui-egui/src/i18n/catalog.rs +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -47,23 +47,42 @@ const MAX_MESSAGE_LEN: usize = 2000; impl Template { /// `None` for a template that can't be matched reliably: no literal text, or two placeholders - /// in a row (where one ends and the next begins is unknowable). + /// in a row (where one ends and the next begins is unknowable). `{{` and `}}` are a literal `{` + /// and `}`, as in a Rust format string, and so is a `{` with no `}` after it. fn parse(source: &str, forms: Vec, counted: bool) -> Option