From fa46998006f8a93d88628954cb49a2d93ffca619 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 05:04:13 -0700 Subject: [PATCH 001/208] =?UTF-8?q?Effects:=20Effect=20=E2=80=BA=20Rasteri?= =?UTF-8?q?ze=20draws=20an=20object=20as=20pixels=20while=20its=20paths=20?= =?UTF-8?q?stay=20editable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Effect menu had no Rasterize. Object › Rasterize replaces the object with an image, so its paths can no longer be edited. Effect › Rasterize… (`rasterize`) is a live effect with Object › Rasterize's options: color model, resolution (the document's raster effects resolution by default), white or transparent background, anti-aliasing, Create Clipping Mask and Add Around Object. The canvas draws the object as square pixels over its bounds, and its paths, type and other art stay as they are. The effects above it in the stack, shadows included, are part of its image, and shadows below it are cast by the image. When Rasterize is an object's only raster effect, Expand Appearance and PDF and SVG export give the image Object › Rasterize gives with the same options. Object › Rasterize now measures the art as drawn, with its geometry effects, brush art and raster effects, so a Transform effect that moves the art no longer leaves the image empty. Its Create Clipping Mask clips the image to the art as drawn. The canvas no longer panics drawing a pixel effect such as Gaussian Blur on an object about 65,500 screen pixels wide: the raster of such an object is at most 32,768 pixels a side, and the object is drawn into it at a lower resolution. Docs: docs/mcp.md, docs/effects-parity.md, docs/target-app-parity.md, ROADMAP. --- ROADMAP.md | 2 +- crates/effects/src/lib.rs | 14 +- crates/effects/src/raster.rs | 285 ++++++++- crates/effects/src/tests_scale.rs | 4 + crates/engine/src/cmd/effectcmd.rs | 9 +- crates/engine/src/cmd/menucmds.rs | 107 ++-- crates/engine/src/cmd/rasterfx.rs | 58 +- crates/engine/src/tests_rastersettings.rs | 153 ++++- crates/render/src/fx.rs | 575 +++++++++++++++++-- crates/render/src/lib.rs | 15 + crates/render/src/tests_rasterize.rs | 423 ++++++++++++++ crates/svg/src/css.rs | 2 +- crates/svg/src/export.rs | 4 +- crates/ui-egui/src/dialogs/effect.rs | 4 + crates/ui-egui/src/dialogs/raster_effects.rs | 92 ++- crates/ui-egui/src/i18n/bg.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/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.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/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 + crates/ui-egui/src/menus.rs | 22 +- crates/ui-egui/src/tests_labels.rs | 12 + docs/effects-parity.md | 4 +- docs/mcp.md | 18 + docs/target-app-parity.md | 3 +- 39 files changed, 1707 insertions(+), 118 deletions(-) create mode 100644 crates/render/src/tests_rasterize.rs diff --git a/ROADMAP.md b/ROADMAP.md index 522d5ffc9..62175078d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)). | | Value | Kind | |---|---|---| -| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 30/57, 3D 4/4 (initial types, no materials) | +| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 30/57, 3D 4/4 (initial types, no materials) | | **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated | | **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated | | **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated | diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 89ba2d491..b3eb5e717 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -5,7 +5,7 @@ //! //! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble, //! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order. -//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the +//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, Rasterize, and the //! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize, Halftone Pattern, //! Unsharp Mask and Glowing Edges) are described by //! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond @@ -68,7 +68,7 @@ pub use group::{ pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner}; pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; -pub use raster::{RasterFx, outset, raster_effects}; +pub use raster::{RASTERIZE, RasterFx, Rasterize, outset, raster_effects}; pub use reshape::{expand_outlined, has_item_geometry, needs_outline, outline_art, outline_text, reshape}; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ @@ -150,6 +150,7 @@ fn lengths_of(id: &str) -> Lengths { "texture.patchwork" => always(&["squareSize"]), "texture.stainedGlass" => always(&["cellSize"]), "sketch.halftonePattern" => always(&["size"]), + RASTERIZE => always(&["addAround"]), _ => Lengths::default(), } } @@ -614,6 +615,13 @@ pub fn effect_catalog() -> Vec { "{style?: \"roman\"|\"japanese\" (default: the japaneseCropMarks preference when applied)} trim marks in [Registration] around the object's bounds, following it", json!({}), )); + v.push(r( + RASTERIZE, + "Rasterize…", + &["Effect"], + "{colorModel: \"document\" (the document's mode, RGB or CMYK)|\"grayscale\"|\"bitmap\" (\"document\"), useDocumentResolution: bool (true: Document Raster Effects Settings › Resolution), resolution: ppi 1..2400 (72; with useDocumentResolution false), background: \"white\"|\"transparent\" (\"transparent\"), antiAlias: \"none\"|\"art\"|\"type\" (\"art\"; none: hard edges), clippingMask: bool (false; the image clipped to the art's outline), addAround: pt 0..1000 (0; room around the art)} draws the object as an image of square pixels over its bounds and the room around them, leaving its paths, type and other art as they are; when it is the object's only raster effect, Expand Appearance makes the image Object › Rasterize makes", + json!({"colorModel": "document", "useDocumentResolution": true, "resolution": 72.0, "background": "transparent", "antiAlias": "art", "clippingMask": false, "addAround": 0.0}), + )); v } @@ -763,7 +771,7 @@ pub fn scale_effect(e: &mut Effect, s: f64) { /// Is `id` a raster (painted) effect? pub fn is_raster(id: &str) -> bool { - matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian") + matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian" | RASTERIZE) || PIXEL_EFFECTS.contains(&id) } diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index 31cc56976..e9a51fc43 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -1,15 +1,19 @@ //! Raster (painted) effects: descriptions for the renderer. use serde_json::Value; -use vectorcraft_doc::Effect; use vectorcraft_doc::color::{BlendMode, Color}; +use vectorcraft_doc::rastersettings::MAX_ADD_AROUND; +use vectorcraft_doc::{Effect, RasterColorModel, RasterEffectsSettings}; -use vectorcraft_geom::Rect; +use vectorcraft_geom::{Point, Rect}; use crate::merged_params; -use crate::pixel::{self, PixelFx}; +use crate::pixel::{self, PixelFx, PixelSpace}; use crate::util::*; +/// Effect › Rasterize's id. +pub const RASTERIZE: &str = "rasterize"; + /// A painted effect, in document units. `blur` is Illustrator's blur distance; the renderer uses /// a Gaussian with σ = blur / 2 (so the visible spread is about 1.5 × blur). #[derive(Clone, Debug, PartialEq)] @@ -48,6 +52,8 @@ pub enum RasterFx { }, /// A Photoshop-style filter over the object's pixels (Radial Blur, Unsharp Mask…). Pixel(PixelFx), + /// Effect › Rasterize: the object drawn as an image of square pixels. + Rasterize(Rasterize), } impl RasterFx { @@ -71,6 +77,278 @@ impl RasterFx { RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => 0.0, RasterFx::GaussianBlur { radius } => 1.5 * radius, RasterFx::Pixel(p) => p.outset(bounds), + RasterFx::Rasterize(r) => r.outset(), + } + } + /// This effect with a Rasterize that takes the document's resolution at `ppi` ([`Rasterize::at_document_ppi`]). + pub fn at_document_ppi(&self, ppi: f64) -> RasterFx { + match self { + RasterFx::Rasterize(r) => RasterFx::Rasterize(r.at_document_ppi(ppi)), + fx => fx.clone(), + } + } + /// What this effect paints over when what it paints around covers `bounds`: Rasterize's image + /// ([`Rasterize::image`], `line` the document's raster grid), `bounds` grown by + /// [`Self::outset`] for the others. + pub fn spread(&self, bounds: Rect, line: f64) -> Rect { + match self { + RasterFx::Rasterize(r) => r.image(bounds, line), + fx => { + let o = fx.outset(bounds); + bounds.inflate(o, o) + } + } + } +} + +/// Effect › Rasterize's options, read from its parameters. The image covers the object's bounds +/// (as far as the effects before it spread the object) and `add_around` points around them, in +/// pixels `72 / ppi` points wide whose first one starts at that area's top-left corner, as Object +/// › Rasterize makes its image. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rasterize { + /// Pixels per inch (1 to 2400); `None` takes Document Raster Effects Settings › Resolution. + pub ppi: Option, + /// The image's color model. + pub color_model: RasterColorModel, + /// A white background: the image is opaque. Otherwise it keeps its transparency. + pub white: bool, + /// Smooth edges (Art Optimized or Type Optimized). Off, each pixel takes the art at its center. + pub anti_alias: bool, + /// Type Optimized: smooth edges with type fitted to the pixels. + pub hinted: bool, + /// The image is clipped to the art's outline. + pub clipping_mask: bool, + /// Room around the art, in points (0 to [`MAX_ADD_AROUND`]). + pub add_around: f64, +} + +impl Rasterize { + /// The options in the effect's parameters `p` (defaults merged in). + fn parse(p: &Value) -> Self { + let ppi = (!flag(p, "useDocumentResolution", true)).then(|| num(p, "resolution", 72.0).clamp(1.0, 2400.0)); + let color_model = match text(p, "colorModel", "document").to_ascii_lowercase().as_str() { + "grayscale" | "gray" => RasterColorModel::Grayscale, + "bitmap" => RasterColorModel::Bitmap, + _ => RasterColorModel::Document, + }; + Self { + ppi, + color_model, + white: text(p, "background", "transparent").eq_ignore_ascii_case("white"), + anti_alias: !text(p, "antiAlias", "art").eq_ignore_ascii_case("none"), + hinted: text(p, "antiAlias", "art").eq_ignore_ascii_case("type"), + clipping_mask: flag(p, "clippingMask", false), + add_around: num(p, "addAround", 0.0).clamp(0.0, MAX_ADD_AROUND), + } + } + + /// The width of a pixel of the image in document units: 72 / `ppi`, or `line` (72 / the + /// document's raster effects resolution) when it takes the document's. + pub fn cell(&self, line: f64) -> f64 { + self.ppi.map_or(line, |ppi| 72.0 / ppi) + } + + /// These options at `ppi` when they take the document's resolution. + pub fn at_document_ppi(self, ppi: f64) -> Self { + Self { ppi: self.ppi.or(Some(ppi)), ..self } + } + + /// How far beyond the object's bounds the image reaches: the room around it and, at a known + /// resolution, one pixel more for the last column and row, which are whole pixels. + pub fn outset(&self) -> f64 { + self.add_around + self.ppi.map_or(0.0, |ppi| 72.0 / ppi) + } + + /// How far (document units) from a pixel the content that decides it lies: across its pixel + /// of the image (72 points when the resolution is the document's, not known here). + pub fn reach(&self) -> f64 { + self.cell(72.0) * std::f64::consts::SQRT_2 + } + + /// The image of art over `bounds`: `bounds` and the room around them, in whole pixels + /// ([`Self::cell`] of `line`) from that area's top-left corner. + pub fn image(&self, bounds: Rect, line: f64) -> Rect { + let cell = self.cell(line); + let area = bounds.inflate(self.add_around, self.add_around); + let (cols, rows) = ((area.width() / cell).ceil().max(1.0), (area.height() / cell).ceil().max(1.0)); + Rect::new(area.x0, area.y0, area.x0 + cols * cell, area.y0 + rows * cell) + } + + /// Give `pixels` (premultiplied RGBA8), the art drawn on the image's own grid, the background + /// and then the color model, as Object › Rasterize does. + pub fn finish(&self, pixels: &mut [u8]) { + for p in pixels.as_chunks_mut::<4>().0 { + if self.white { + let under = 255 - p[3]; + *p = p.map(|c| c.saturating_add(under)); + } + } + RasterEffectsSettings { color_model: self.color_model, ..Default::default() }.finish_pixels(pixels); + } + + /// Draw `image` into `data`, `w` × `h` premultiplied RGBA8 pixels lying in the document as + /// `space` says: `image` is `cols` × `rows` premultiplied RGBA8 pixels covering `rect` in the + /// document. In a view that isn't turned, each pixel of `data` takes the mean of the image over + /// its area (transparent outside the image); otherwise the mean of the image at up to 8 × 8 + /// points across it. Does nothing when the sizes don't match. + #[allow(clippy::too_many_arguments)] + pub fn compose(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace, image: &[u8], cols: usize, rows: usize, rect: Rect) { + let (px, rest) = data.as_chunks_mut::<4>(); + let (src, src_rest) = image.as_chunks::<4>(); + let fits = |n: usize, w: usize, h: usize, rest: usize| w > 0 && h > 0 && rest == 0 && w.checked_mul(h) == Some(n); + if !fits(px.len(), w, h, rest.len()) || !fits(src.len(), cols, rows, src_rest.len()) || !(rect.width() > 0.0 && rect.height() > 0.0) { + return; + } + let (sx, sy) = (cols as f64 / rect.width(), rows as f64 / rect.height()); + // Document → image pixels. + let to_image = |p: Point| ((p.x - rect.x0) * sx, (p.y - rect.y0) * sy); + let at = |i: f64, j: f64| { + (i >= 0.0 && j >= 0.0 && i < cols as f64 && j < rows as f64).then(|| src.get(j as usize * cols + i as usize).copied()).flatten() + }; + let straight = matches!(space.to_doc.as_coeffs(), [_, b, c, _, _, _] if b == 0.0 && c == 0.0); + // Points across a raster pixel in a turned view: about one an image pixel. + let n = (space.px * sx.max(sy)).ceil().clamp(1.0, 8.0) as usize; + for (k, p) in px.iter_mut().enumerate() { + let (x, y) = ((k % w) as f64, (k / w) as f64); + let mut acc = [0.0f64; 4]; + let weight = if straight { + let (a, b) = (to_image(space.to_doc * Point::new(x, y)), to_image(space.to_doc * Point::new(x + 1.0, y + 1.0))); + let (u0, u1, v0, v1) = (a.0.min(b.0), a.0.max(b.0), a.1.min(b.1), a.1.max(b.1)); + let (i0, i1) = (u0.floor().max(0.0), u1.ceil().min(cols as f64)); + let (j0, j1) = (v0.floor().max(0.0), v1.ceil().min(rows as f64)); + let mut j = j0; + while j < j1 { + let mut i = i0; + while i < i1 { + let share = (u1.min(i + 1.0) - u0.max(i)).max(0.0) * (v1.min(j + 1.0) - v0.max(j)).max(0.0); + if let Some(q) = at(i, j) { + for (s, v) in acc.iter_mut().zip(q) { + *s += share * f64::from(v); + } + } + i += 1.0; + } + j += 1.0; + } + (u1 - u0) * (v1 - v0) + } else { + for t in 0..n * n { + let (fx, fy) = (((t % n) as f64 + 0.5) / n as f64, ((t / n) as f64 + 0.5) / n as f64); + let (i, j) = to_image(space.to_doc * Point::new(x + fx, y + fy)); + if let Some(q) = at(i.floor(), j.floor()) { + for (s, v) in acc.iter_mut().zip(q) { + *s += f64::from(v); + } + } + } + (n * n) as f64 + }; + *p = if weight > 0.0 { acc.map(|s| (s / weight).round().clamp(0.0, 255.0) as u8) } else { [0; 4] }; + } + } + + /// Turn `data`, `w` × `h` premultiplied RGBA8 pixels of the object lying in the document as + /// `space` says, into its image over `bounds` (what the object covers after the effects before + /// this one), when the image is too large to draw on its own grid. Each pixel of the image more + /// than a raster pixel wide takes the mean of the raster pixels whose centers lie in it + /// (anti-aliased) or the raster pixel at its center (not anti-aliased). Smaller pixels are left + /// as drawn. Then the background and the color model apply as Object › Rasterize applies them. + /// What lies outside the image becomes transparent, and in a view that isn't turned the raster + /// pixels along its edges keep the share of them it covers. Does nothing when the sizes don't + /// match. The renderer draws the content with hard edges when it goes into Rasterize with + /// anti-aliasing off and image pixels at least a raster pixel wide. + pub fn apply(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace, bounds: Rect) { + let cell = self.cell(space.line); + let (px, rest) = data.as_chunks_mut::<4>(); + if w == 0 || h == 0 || !rest.is_empty() || w.checked_mul(h) != Some(px.len()) || !(cell.is_finite() && cell > 0.0 && space.px > 0.0) { + return; + } + let area = self.image(bounds, space.line); + let (cols, rows) = ((area.width() / cell).round().max(1.0), (area.height() / cell).round().max(1.0)); + // The pixel of the image (column, row) that a raster pixel's center lies in. + let pixel_at = |x: usize, y: usize| { + let p = space.to_doc * Point::new(x as f64 + 0.5, y as f64 + 0.5); + let (c, r) = (((p.x - area.x0) / cell).floor(), ((p.y - area.y0) / cell).floor()); + (c >= 0.0 && r >= 0.0 && c < cols && r < rows).then_some((c, r)) + }; + let wide = cell / space.px; + // The image's pixels the raster covers: columns c0..c1, rows r0..r1. + let seen = space.to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); + let (c0, c1) = (((seen.x0 - area.x0) / cell).floor().clamp(0.0, cols), ((seen.x1 - area.x0) / cell).ceil().clamp(0.0, cols)); + let (r0, r1) = (((seen.y0 - area.y0) / cell).floor().clamp(0.0, rows), ((seen.y1 - area.y0) / cell).ceil().clamp(0.0, rows)); + let span = (c1 - c0) as usize; + let slot = |(c, r): (f64, f64)| ((r - r0) as usize).saturating_mul(span).saturating_add((c - c0) as usize); + // Anti-aliased: the sums of each pixel's raster pixels (premultiplied) and their count, + // which saturate past 16 million raster pixels to an image pixel. They are kept for the + // whole grid when it has no more pixels than the raster, else for the pixels the raster + // reaches (a turned view of a narrow raster). + let grid = span.saturating_mul((r1 - r0) as usize); + let whole = grid <= px.len(); + let mut dense: Vec<[u32; 5]> = vec![]; + let mut sparse: std::collections::HashMap = std::collections::HashMap::new(); + if self.anti_alias && wide > 1.0 { + if whole { + dense = vec![[0; 5]; grid]; + } + for (i, p) in px.iter().enumerate() { + let Some(k) = pixel_at(i % w, i / w).map(slot) else { continue }; + let s = if whole { dense.get_mut(k) } else { Some(sparse.entry(k).or_default()) }; + if let Some(s) = s { + for (acc, v) in s.iter_mut().zip(p) { + *acc = acc.saturating_add(u32::from(*v)); + } + s[4] = s[4].saturating_add(1); + } + } + } + let sum = |k: usize| if whole { dense.get(k) } else { sparse.get(&k) }; + // Not anti-aliased: the raster as drawn, to read each pixel's center from. + let drawn = (!self.anti_alias && wide > 1.0).then(|| px.to_vec()); + let to_raster = space.to_doc.inverse(); + let model = RasterEffectsSettings { color_model: self.color_model, ..Default::default() }; + // In a view that isn't turned, the raster pixels along the image's edges take the share of + // them it covers; otherwise those whose center lies in it are in it. + let straight = matches!(space.to_doc.as_coeffs(), [_, b, c, _, _, _] if b == 0.0 && c == 0.0); + let image = to_raster.transform_rect_bbox(area); + let cover = + |x: f64, y: f64| (image.x1.min(x + 1.0) - image.x0.max(x)).clamp(0.0, 1.0) * (image.y1.min(y + 1.0) - image.y0.max(y)).clamp(0.0, 1.0); + for (i, p) in px.iter_mut().enumerate() { + let (x, y) = (i % w, i / w); + let (at, share) = if straight { + // The image pixel nearest the raster pixel's center. + let q = space.to_doc * Point::new(x as f64 + 0.5, y as f64 + 0.5); + let at = (((q.x - area.x0) / cell).floor().clamp(0.0, cols - 1.0), ((q.y - area.y0) / cell).floor().clamp(0.0, rows - 1.0)); + (Some(at), cover(x as f64, y as f64)) + } else { + (pixel_at(x, y), 1.0) + }; + let Some(at) = at.filter(|_| share > 0.0) else { + *p = [0; 4]; + continue; + }; + // An image pixel's color, over the share of the raster pixel the image covers. (A raster + // pixel left as drawn holds art the image covers whole.) + let made = if let Some(s) = sum(slot(at)).filter(|s| s[4] > 0) { + Some([0, 1, 2, 3].map(|k| ((u64::from(s[k]) + u64::from(s[4] / 2)) / u64::from(s[4])).min(255) as u8)) + } else { + drawn.as_ref().map(|drawn| { + let center = to_raster * Point::new(area.x0 + (at.0 + 0.5) * cell, area.y0 + (at.1 + 0.5) * cell); + let (x, y) = (center.x.floor(), center.y.floor()); + let inside = x >= 0.0 && y >= 0.0 && x < w as f64 && y < h as f64; + if inside { drawn.get(y as usize * w + x as usize).copied().unwrap_or_default() } else { [0; 4] } + }) + }; + if let Some(v) = made { + *p = if share < 1.0 { v.map(|c| (f64::from(c) * share).round() as u8) } else { v }; + } + // White goes under the art first, over the image's share of the pixel, then the color + // model applies, as in Object › Rasterize. + if self.white { + let under = ((255.0 * share).round() as u8).saturating_sub(p[3]); + *p = p.map(|c| c.saturating_add(under)); + } + model.finish_pixels(p); } } } @@ -133,6 +411,7 @@ pub fn raster_effects(effects: &[Effect]) -> Vec { }, "stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) }, "blur.gaussian" => RasterFx::GaussianBlur { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) }, + RASTERIZE => RasterFx::Rasterize(Rasterize::parse(&p)), id => RasterFx::Pixel(pixel::parse(id, &p)?), }) }) diff --git a/crates/effects/src/tests_scale.rs b/crates/effects/src/tests_scale.rs index 420a273a5..6e2c71689 100644 --- a/crates/effects/src/tests_scale.rs +++ b/crates/effects/src/tests_scale.rs @@ -23,6 +23,10 @@ fn scale_effect_scales_distances_only() { scale_effect(&mut e, 2.0); assert_eq!((&e.params["x"], &e.params["y"], &e.params["blur"]), (&json!(6.0), &json!(8.0), &json!(10.0))); assert_eq!(e.params["opacity"], json!(75.0)); + // Rasterize's Add Around Object is a distance; its resolution isn't. + let mut r = new_effect(RASTERIZE, &json!({"addAround": 10, "resolution": 150})).unwrap(); + scale_effect(&mut r, 2.0); + assert_eq!((&r.params["addAround"], &r.params["resolution"]), (&json!(20.0), &json!(150))); // Outline Stroke without a weight follows its stroke's: nothing to scale. let mut o = Effect { id: "path.outlineStroke".into(), params: json!(null), visible: true }; scale_effect(&mut o, 2.0); diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 1781f3355..e7e70d16d 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -59,7 +59,7 @@ pub fn specs() -> Vec { "Expand Appearance", ["Object"], None, - "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids}", + "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids}", can_expand, expand_appearance ), @@ -421,9 +421,10 @@ fn is_container(n: &Node) -> bool { } /// The raster effects of `n` (its own, its fills' and strokes') as an embedded image rendered at -/// the document's raster effects resolution, with whether they all paint below it (shadows and -/// outer glows: the image then holds just them). A group's or layer's image leaves out its -/// members' raster effects, which are expanded with them. `None` when it has none. +/// the document's raster effects resolution or a Rasterize effect's finer one, with whether they +/// all paint below it (shadows and outer glows: the image then holds just them). A group's or +/// layer's image leaves out its members' raster effects, which are expanded with them. `None` +/// when it has none. fn raster_image(d: &mut Document, n: &Node) -> Option<(Node, bool)> { let fx = rasterfx::raster_fx(n); if fx.is_empty() { diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 465e38851..8f159d2e4 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -425,21 +425,49 @@ fn render_png( Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height)) } -fn rasterize(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.rasterize"; - let roots = selected_roots(s)?; - let st = s.doc()?; - let ppi = f64_or(p, "ppi", st.doc.raster_effects_ppi); +/// The image Object › Rasterize makes of some art, before it goes in a document +/// ([`rasterize_art`]). +pub(crate) struct Rasterized { + png: Vec, + width: u32, + height: u32, + /// Image pixels → document. + xf: Affine, + /// The art's outline, with Create Clipping Mask. + clip: Option<(PathData, vectorcraft_geom::FillRule)>, +} + +impl Rasterized { + /// The image as a new object of `d` (its pixels in `d.images`), in a clip group with the art's + /// outline when it has one. + pub(crate) fn into_node(self, d: &mut vectorcraft_doc::Document) -> Node { + let key = unique_key(d, "raster"); + d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", self.png)); + let id = d.alloc_id(); + let image = ImageObject { key, width: self.width, height: self.height, xf: self.xf, link: None, placement: Default::default() }; + let node = Node::new(id, NodeKind::Image(Box::new(image))); + match self.clip { + Some((path, rule)) => super::rasterfx::clip_group(d, path, rule, node), + None => node, + } + } +} + +/// Object › Rasterize's image of `nodes` (drawn also where hidden) with the params `p` of command +/// `cmd`, each defaulting to `doc`'s raster effects settings: over the bounds of what the art draws +/// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels. +pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec, p: &Value, cmd: &str) -> Result { + let ppi = f64_or(p, "ppi", doc.raster_effects_ppi); if !(1.0..=2400.0).contains(&ppi) { - return Err(bad(C, "ppi must be between 1 and 2400")); + return Err(bad(cmd, "ppi must be between 1 and 2400")); } // The document's raster effects settings, changed by the params. - let mut look = st.doc.raster_effects.clone(); + let mut look = doc.raster_effects.clone(); let mut background = (look.background == vectorcraft_doc::Background::White).then_some([255; 3]); let mut anti_alias = if look.anti_alias { AntiAlias::Art } else { AntiAlias::None }; for (k, v) in p.as_object().into_iter().flatten().filter(|(_, v)| !v.is_null()) { match k.as_str() { - "background" => background = fileio::background(v).map_err(|e| bad(C, e))?, + "background" => background = fileio::background(v).map_err(|e| bad(cmd, e))?, "antiAlias" => { anti_alias = match v { Value::Bool(b) => { @@ -449,50 +477,65 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { AntiAlias::None } } - v => fileio::anti_alias(v.as_str().unwrap_or_default()).map_err(|e| bad(C, e))?, + v => fileio::anti_alias(v.as_str().unwrap_or_default()).map_err(|e| bad(cmd, e))?, } } - "padding" | "addAround" => look.add_around = super::rasterfx::add_around(v, C, k)?, - "colorModel" => look.color_model = super::rasterfx::color_model(v, st.doc.color_mode, C)?, - "clippingMask" => look.clipping_mask = v.as_bool().ok_or_else(|| bad(C, "clippingMask must be true or false"))?, + "padding" | "addAround" => look.add_around = super::rasterfx::add_around(v, cmd, k)?, + "colorModel" => look.color_model = super::rasterfx::color_model(v, doc.color_mode, cmd)?, + "clippingMask" => look.clipping_mask = v.as_bool().ok_or_else(|| bad(cmd, "clippingMask must be true or false"))?, _ => {} } } let raster = RasterExportOptions { ppi, background, anti_alias, ..Default::default() }; let pad = look.add_around; - let b = st.doc.bounds_of(&roots, true).ok_or_else(|| bad(C, "selection has no bounds"))?.inflate(pad, pad); - let scale = ppi / 72.0; - // Snap to whole pixels. - let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale); - let nodes: Vec = roots - .iter() - .filter_map(|id| st.doc.node(*id).cloned()) + let nodes: Vec = nodes + .into_iter() .map(|mut n| { n.visible = true; n }) .collect(); - // Create Clipping Mask: the image is clipped to the art's outline. + // What the art draws, its geometry effects applied. + let b = nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, vectorcraft_render::drawn_bounds(doc, n))); + let b = b.ok_or_else(|| bad(cmd, "selection has no bounds"))?.inflate(pad, pad); + let scale = ppi / 72.0; + // Snap to whole pixels. + let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale); + // Create Clipping Mask: the image is clipped to the art's outline with its geometry effects + // applied, as on the canvas. let clip = look .clipping_mask - .then(|| vectorcraft_render::effects::clip_outline(&Node::group(NodeId(u64::MAX), nodes.iter().cloned().map(Arc::new).collect()))) + .then(|| { + let outlines = nodes.iter().filter_map(|n| { + let (bp, rule) = vectorcraft_render::drawn_outline(doc, n)?; + let mut p = Node::path(n.id, PathData::from_bezpath(&bp), Appearance::default()); + if let NodeKind::Path { rule: r, .. } = &mut p.kind { + *r = rule; + } + Some(Arc::new(p)) + }); + vectorcraft_render::effects::clip_outline(&Node::group(NodeId(u64::MAX), outlines.collect())) + }) .flatten() .map(|(bp, rule)| (PathData::from_bezpath(&bp), rule)); - let tmp = isolated_doc(&st.doc, nodes); - let (png, w, h) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?; + let tmp = isolated_doc(doc, nodes); + let (png, width, height) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?; + let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); + Ok(Rasterized { png, width, height, xf, clip }) +} + +fn rasterize(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.rasterize"; + let roots = selected_roots(s)?; + let st = s.doc()?; + let nodes: Vec = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).collect(); + let image = rasterize_art(&st.doc, nodes, p, C)?; + let (w, h) = (image.width, image.height); if roots.is_empty() { return Err(bad(C, "nothing selected")); } let id = s.edit("Rasterize", |d, sel| { - let key = unique_key(d, "raster"); - d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png)); - let id = d.alloc_id(); - let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); - let mut node = - Node::new(id, NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }))); - if let Some((path, rule)) = clip { - node = super::rasterfx::clip_group(d, path, rule, node); - } + let node = image.into_node(d); let id = d.wrap(&roots, |_| node)?; sel.set([id]); Ok(id) diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index fc3170464..f0b02eb20 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -6,11 +6,13 @@ //! model, background, anti-aliasing, room around the art). Shadows and outer glows only add the //! effect: the image (with the object's own area knocked out) goes under the untouched vector //! object. Effects that change the object itself (inner glow, feather, Gaussian blur) replace it -//! with the image. Expand Appearance makes its images with the same helpers. +//! with the image. An object whose only raster effect is a Rasterize of its own is replaced with +//! the image Object › Rasterize makes with that effect's options. Expand Appearance makes its +//! images with the same helpers. //! //! SVG has filters for shadows, glows, blurs and feathers but none for the Photoshop-style -//! effects (Radial Blur, Unsharp Mask…): SVG export turns only the objects carrying one of those -//! into images the same way ([`flatten_pixel_effects`]). +//! effects (Radial Blur, Unsharp Mask…) or Rasterize: SVG export turns only the objects carrying +//! one of those into images the same way ([`flatten_pixel_effects`]). use std::sync::Arc; @@ -273,11 +275,15 @@ fn below_images(d: &mut Document, n: &Node) -> Option> { } /// The raster effects of `n` (its own, its fills' and strokes') as an embedded image of `d` -/// rendered at the document's raster effects resolution, without `n`'s transparency (that stays -/// on the object). With `below` (they all paint below it: shadows and outer glows) the image holds -/// just them; with `members` too, a group's or layer's image leaves out its members' raster -/// effects (they get images of their own). +/// rendered at the document's raster effects resolution, or at a Rasterize effect's finer one (an +/// object whose only raster effect is its own Rasterize gets [`rasterize_effect_image`]'s image), +/// without `n`'s transparency (that stays on the object). With `below` (they all paint below it: +/// shadows and outer glows) the image holds just them; with `members` too, a group's or layer's +/// image leaves out its members' raster effects (they get images of their own). pub(crate) fn raster_image(d: &mut Document, n: &Node, below: bool, members: bool) -> Option { + if !below && let Some(image) = rasterize_effect_image(d, n) { + return Some(image); + } let mut whole = n.clone(); (whole.opacity, whole.blend) = (1.0, Default::default()); whole.extras_mut().mask = None; @@ -291,10 +297,37 @@ pub(crate) fn raster_image(d: &mut Document, n: &Node, below: bool, members: boo strip_raster(&mut b, false); b }); - let scale = effects_scale(d); + // A Rasterize effect at a finer resolution than the document's is rendered at its own. + let scale = raster_fx(n) + .iter() + .filter_map(|fx| if let RasterFx::Rasterize(r) = fx { r.ppi } else { None }) + .fold(effects_scale(d), |s, ppi| s.max(ppi / 72.0)); effect_image(d, &whole, bare.as_ref(), scale) } +/// The image Object › Rasterize makes of `n` with the options of `n`'s Effect › Rasterize, when +/// that is its only visible raster effect and is its own (not a fill's or a stroke's): made of `n` +/// without that effect and without its transparency, which stays on the object. `None` otherwise, +/// or when the image can't be made. +fn rasterize_effect_image(d: &mut Document, n: &Node) -> Option { + let [RasterFx::Rasterize(r)] = raster_fx(n)[..] else { return None }; + let at = n.appearance.effects.iter().position(|e| e.visible && e.id == effects::RASTERIZE)?; + let p = json!({ + "ppi": r.ppi.unwrap_or(d.raster_effects_ppi), + "colorModel": r.color_model.id(), + "background": if r.white { "white" } else { "transparent" }, + "antiAlias": if !r.anti_alias { "none" } else if r.hinted { "type" } else { "art" }, + "clippingMask": r.clipping_mask, + "addAround": r.add_around, + }); + let mut whole = n.clone(); + whole.appearance.effects.remove(at); + (whole.opacity, whole.blend) = (1.0, Default::default()); + whole.extras_mut().mask = None; + let image = super::menucmds::rasterize_art(d, vec![whole], &p, "object.rasterize").ok()?; + Some(image.into_node(d)) +} + /// `v` (an object without its raster effects) replaced by `image` of the object with them: the /// image takes its place with its id, name and transparency (a layer keeps the image as its only /// member). @@ -393,7 +426,8 @@ fn placed_symbol(d: &Document, n: &Node) -> Option { enum Which { /// Every raster effect (PDF). All, - /// Objects with a Photoshop-style effect, which SVG can't draw (with all their raster effects). + /// Objects with a Photoshop-style effect or Rasterize, which SVG can't draw (with all their + /// raster effects). Pixel, } @@ -402,7 +436,7 @@ fn imaged(n: &Node, which: Which) -> bool { let fx = raster_fx(n); match which { Which::All => !fx.is_empty(), - Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_))), + Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_))), } } @@ -499,8 +533,8 @@ pub fn flatten_raster_effects(doc: &Document) -> Option { } /// A copy of `doc` in which the objects with Photoshop-style raster effects (Radial Blur, Unsharp -/// Mask…) are images as [`flatten_raster_effects`] makes them (SVG export: its filters draw the -/// other raster effects live), or `None` when there are none. +/// Mask…) or Rasterize are images as [`flatten_raster_effects`] makes them (SVG export: its +/// filters draw the other raster effects live), or `None` when there are none. pub fn flatten_pixel_effects(doc: &Document) -> Option { flatten(doc, &mut FlattenReport::new(Which::Pixel, doc)) } diff --git a/crates/engine/src/tests_rastersettings.rs b/crates/engine/src/tests_rastersettings.rs index 577c801cd..428142e2b 100644 --- a/crates/engine/src/tests_rastersettings.rs +++ b/crates/engine/src/tests_rastersettings.rs @@ -1,5 +1,6 @@ //! Effect → Document Raster Effects Settings (`document.rasterEffectsSettings`): the settings, the -//! raster effect images they make (PDF export, Expand Appearance) and the Rasterize defaults. +//! raster effect images they make (PDF export, Expand Appearance), the Rasterize defaults, and +//! Effect › Rasterize's image. use serde_json::{Value, json}; use vectorcraft_doc::{Background, Document, NodeId, NodeKind, RasterColorModel, RasterEffectsSettings}; @@ -144,6 +145,156 @@ fn rasterize_takes_the_document_settings_as_defaults() { assert!(s.execute("object.rasterize", &json!({"colorModel": "lab"})).is_err()); } +/// An image Object › Rasterize or Expand Appearance made, `n` or the image in its clip group: +/// (width, height, where its pixels lie, its PNG, the clipping path's geometry). +fn rasterized(d: &Document, n: &vectorcraft_doc::Node) -> (u32, u32, vectorcraft_geom::Affine, Vec, Option) { + let (image, clip) = match &n.kind { + NodeKind::Group { children, clip: true } => (&*children[1], children[0].path_data().cloned()), + _ => (n, None), + }; + let NodeKind::Image(im) = &image.kind else { panic!("an image: {:?}", image.kind_label()) }; + (im.width, im.height, im.xf, d.images[&im.key].bytes.to_vec(), clip) +} + +/// A rectangle of 40 × 30 pt with its default stroke and a red fill, selected. +fn red_rectangle(s: &mut Session) -> NodeId { + let r = id(&s.execute("shape.rectangle", &json!({"x": 50.5, "y": 50.25, "width": 40, "height": 30})).unwrap()); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [r.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + r +} + +#[test] +fn the_rasterize_effect_keeps_the_path_and_expands_to_the_image_object_rasterize_makes() { + let cases = [ + ( + json!({"useDocumentResolution": false, "resolution": 150, "background": "white", "colorModel": "grayscale", "antiAlias": "none", "addAround": 3}), + json!({"ppi": 150, "background": "white", "colorModel": "grayscale", "antiAlias": "none", "addAround": 3}), + ), + // At the document's resolution (300 ppi here), clipped to the art's outline. + ( + json!({"clippingMask": true, "antiAlias": "type"}), + json!({"ppi": 300, "clippingMask": true, "antiAlias": "type", "background": "transparent"}), + ), + ]; + for (effect, object) in cases { + let mut s = session(); + s.execute(C, &json!({"resolution": "high"})).unwrap(); + let r = red_rectangle(&mut s); + let before = s.doc().unwrap().doc.node(r).unwrap().path_data().cloned(); + s.execute("effect.apply", &json!({"effect": "rasterize", "params": effect, "ids": [r.0]})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let n = d.node(r).unwrap(); + assert_eq!((n.path_data().cloned(), n.appearance.effects[0].id.as_str()), (before, "rasterize"), "the path stays as it was"); + // Saved and read back. + let back = vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap(); + assert_eq!(back.node(r).unwrap().appearance.effects, n.appearance.effects); + // Object › Rasterize of the same rectangle. + let mut o = session(); + o.execute(C, &json!({"resolution": "high"})).unwrap(); + red_rectangle(&mut o); + let made = id(&o.execute("object.rasterize", &object).unwrap()); + let od = &o.doc().unwrap().doc; + let want = rasterized(od, od.node(made).unwrap()); + // PDF export draws that image in its place. + let flat = crate::flatten_raster_effects(&d).unwrap(); + assert_eq!(rasterized(&flat, &flat.layers[0].children().unwrap()[0]), want, "{effect}"); + // So does Expand Appearance. + s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(rasterized(d, d.node(r).unwrap()), want, "{effect}"); + } +} + +#[test] +fn the_rasterize_effect_follows_edits_to_the_path() { + let mut s = session(); + let r = red_rectangle(&mut s); + s.execute("effect.apply", &json!({"effect": "rasterize", "params": {"background": "white"}, "ids": [r.0]})).unwrap(); + let width = |s: &Session| { + let flat = crate::flatten_raster_effects(&s.doc().unwrap().doc).unwrap(); + rasterized(&flat, &flat.layers[0].children().unwrap()[0]).0 + }; + let before = width(&s); + // Direct Selection moves the right-hand anchors 20 pt: the image widens with the path. + s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 1], [0, 2]]})).unwrap(); + s.execute("path.moveAnchors", &json!({"dx": 20, "dy": 0})).unwrap(); + let n = s.doc().unwrap().doc.node(r).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Path { .. }) && n.appearance.effects.len() == 1); + assert_eq!(width(&s), before + 20, "72 ppi: a pixel a point"); +} + +#[test] +fn rasterize_measures_the_art_its_geometry_effects_draw() { + let mut s = session(); + let r = red_rectangle(&mut s); + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 100}, "ids": [r.0]})).unwrap(); + s.execute("effect.apply", &json!({"effect": "rasterize", "params": {"background": "white"}, "ids": [r.0]})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap(); + let d = &s.doc().unwrap().doc; + let (width, _, xf, ..) = rasterized(d, d.node(r).unwrap()); + // The rectangle as the Transform effect draws it: 100 pt to the right, 41 pt wide with its stroke. + assert_eq!((xf.translation().x, width), (150.0, 41)); +} + +#[test] +fn the_clipping_mask_follows_the_art_its_geometry_effects_draw() { + let clip = |s: &Session, n: NodeId| match &s.doc().unwrap().doc.node(n).unwrap().kind { + NodeKind::Group { children, clip: true } => children[0].geometric_bounds(), + _ => None, + }; + let moved = |s: &mut Session, n: NodeId| { + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 100}, "ids": [n.0]})).unwrap(); + }; + // Expand Appearance clips the image to the rectangle drawn 100 pt to the right, from 150.5 to + // 190.5 pt. + let mut s = session(); + let r = red_rectangle(&mut s); + moved(&mut s, r); + s.execute("effect.apply", &json!({"effect": "rasterize", "params": {"clippingMask": true}, "ids": [r.0]})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap(); + let b = clip(&s, r).expect("a clip group"); + assert!((b.x0 - 150.5).abs() < 1e-6 && (b.x1 - 190.5).abs() < 1e-6, "{b:?}"); + // Object › Rasterize clips its image there too. + let mut s = session(); + let r = red_rectangle(&mut s); + moved(&mut s, r); + let made = id(&s.execute("object.rasterize", &json!({"clippingMask": true})).unwrap()); + let b = clip(&s, made).expect("a clip group"); + assert!((b.x0 - 150.5).abs() < 1e-6 && (b.x1 - 190.5).abs() < 1e-6, "{b:?}"); + // Type's image is clipped to its glyphs, 100 pt right of where they are without the effect. + let glyphs = |effect: bool| { + let mut s = session(); + let t = id(&s.execute("text.create", &json!({"x": 50, "y": 100, "text": "H", "size": 40})).unwrap()); + if effect { + moved(&mut s, t); + } + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + let made = id(&s.execute("object.rasterize", &json!({"clippingMask": true})).unwrap()); + clip(&s, made).expect("a clip group") + }; + let (plain, shifted) = (glyphs(false), glyphs(true)); + assert!((shifted.x0 - plain.x0 - 100.0).abs() < 1e-6 && (shifted.x1 - plain.x1 - 100.0).abs() < 1e-6, "{plain:?} {shifted:?}"); +} + +#[test] +fn object_rasterize_takes_in_everything_an_effect_stack_paints() { + let mut s = session(); + let r = red_rectangle(&mut s); + s.execute("effect.apply", &json!({"effect": "blur.gaussian", "params": {"radius": 4}, "ids": [r.0]})).unwrap(); + let live = json!({"useDocumentResolution": false, "resolution": 72, "background": "white", "addAround": 10}); + s.execute("effect.apply", &json!({"effect": "rasterize", "params": live, "ids": [r.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let made = id(&s.execute("object.rasterize", &json!({"ppi": 72, "background": "transparent"})).unwrap()); + let d = &s.doc().unwrap().doc; + let (width, height, xf, png, _) = rasterized(d, d.node(made).unwrap()); + let img = image::load_from_memory(&png).unwrap().to_rgba8(); + // 41 pt of rectangle and stroke, and 6 pt of blur and 10 pt of white on each side: white from + // the first column to the last. + assert_eq!((width, xf.translation().x), (73, 34.0)); + assert_eq!((img.get_pixel(0, height / 2).0, img.get_pixel(width - 1, height / 2).0), ([255; 4], [255; 4])); +} + #[test] fn raster_settings_round_trip_and_old_files_load() { let mut d = Document::new(10.0, 10.0); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 23352a280..01075f5ae 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -17,6 +17,12 @@ //! filters, Gaussian Blur and Feather, see [`effects::pixel`]), then drawn back //! ([`Renderer::pixel_content`]). The result is cached per object while it and the zoom don't //! change. +//! - Rasterize: the content drawn again on the image's own grid, as Object › Rasterize draws it +//! ([`Renderer::rasterize_image`]), then into the content's raster +//! ([`effects::Rasterize::compose`]; an image too large for that is made from the content's +//! raster, [`effects::Rasterize::apply`]), clipped to its outline with Create Clipping Mask. The +//! effects before it in the stack, and the shadows and glows after it, are drawn around it by +//! splitting the stack there ([`Renderer::raster_fx`]). //! //! While [`crate::RenderOptions::interacting`], an object whose content only moved draws its //! cached shadows, glows and filtered content at its new place instead of running the effects @@ -108,6 +114,41 @@ struct Content<'a> { /// Its silhouette: Feather and Inner Glow clip to it (without one, Feather only blurs and /// Inner Glow is skipped). outline: Option<(&'a BezPath, FillRule)>, + /// What Rasterize makes its image over: the object's [`drawn_bounds`] as far as the effects + /// before it spread them (`None`: `reach`). + bounds: Option, + /// Where the image of the Rasterize first in these effects is kept for the effects after a + /// split ([`Renderer::raster_fx`]), which draw this content more than once. + kept: Option<&'a Kept>, +} + +/// The image of the last Rasterize before a split in an effect stack ([`Renderer::raster_fx`]). It +/// is made the first time the effects after the split draw the content and drawn from here the +/// other times, so the content before the split is drawn once. +struct Kept { + /// Where the effects after the split take the content from: the visible area grown by how + /// far they paint. + window: Rect, + /// The view and pixel size of the split's frame, when the image is over + /// [`Renderer::rasterize_image`]'s limits: the content is then drawn in that frame. + frame: Option<(Affine, f64)>, + image: std::cell::RefCell>, +} + +#[derive(Clone)] +enum KeptImage { + /// The image on its own grid, as [`Renderer::rasterize_image`] makes it. + Grid(std::rc::Rc<(Vec, usize, usize)>), + /// An image over [`Renderer::rasterize_image`]'s limits: the content's raster with the + /// Rasterize applied, as the first draw made it, and the transform from its pixels to + /// document space. + Raster(Arc, Affine), +} + +/// The [`Content::bounds`] of `n` under raster effects `rfx`: its [`drawn_bounds`] when `rfx` +/// holds a Rasterize. +fn rasterize_bounds(symbols: &SymbolBoxes, n: &Node, rfx: &[RasterFx]) -> Option { + rfx.iter().any(|fx| matches!(fx, RasterFx::Rasterize(_))).then(|| drawn_bounds(symbols, n)).flatten() } /// `f` with the content moved by (`dx`, `dy`) document units (a shadow's offset). @@ -155,7 +196,7 @@ fn item_effects(item: &AppearanceItem) -> &[Effect] { pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) { return cull_bounds(symbols, &art).map(|b| { - let o = effects::outset(&n.appearance.effects, b); + let o = outset(symbols, &n.appearance.effects, b); b.inflate(o, o) }); } @@ -176,11 +217,173 @@ pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { let s = n.appearance.outset(); r.map(|r| { let r = r.inflate(s, s); - let o = effects::outset(&n.appearance.effects, r); + // The fills' and strokes' own raster effects, then the object's. + let r = n.appearance.items.iter().filter(|i| paints(i)).map(|i| spread(symbols, item_effects(i), r)).fold(r, |acc, b| acc.union(b)); + let o = outset(symbols, &n.appearance.effects, r); r.inflate(o, o) }) } +/// Does `item` paint (as [`Renderer::paint_items`] draws it)? +fn paints(item: &AppearanceItem) -> bool { + item.visible() && !item.paint().is_none() +} + +/// The bounds of what `n` draws, its own and its members' geometry effects applied and the raster +/// effects of its members and of its fills and strokes included (each fill's and stroke's +/// spreading what it paints, [`item_bounds`]), with strokes measured as +/// [`Node::visual_bounds_with`] measures them, without the reach of `n`'s own raster effects +/// (symbol instances measured by their art from `symbols`): what Rasterize makes its image over. +pub(crate) fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { + let b = art_bounds(symbols, n)?; + let items = n.appearance.items.iter().filter(|i| paints(i) && !effects::raster_effects(item_effects(i)).is_empty()); + Some(items.map(|i| spread(symbols, item_effects(i), item_bounds(n, i).unwrap_or(b))).fold(b, |acc, r| acc.union(r))) +} + +/// The bounds of what `item`, a fill or stroke of the path or compound path `n`, paints: the path +/// with `n`'s and `item`'s geometry effects, and for a stroke its width. `None` for other objects, +/// 3D art, a brushed stroke and an item that draws no path. +fn item_bounds(n: &Node, item: &AppearanceItem) -> Option { + let stroke = matches!(item, AppearanceItem::Stroke(_)); + if !has_fx(n) || effects::has_revolve(n) || effects::has_solid(n) || (stroke && vectorcraft_brush::has_brush(n)) { + return None; + } + let g = effected_path(n, &node_bezpath(n)?); + let fx = item_effects(item); + if effects::has_geometry(fx) { + let ig = apply(fx, &g, &GeomContext::of(n).item(item)); + let o = if let AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 }; + return (!ig.elements().is_empty()).then(|| ig.bounding_box().inflate(o, o)); + } + let one = vectorcraft_doc::Appearance { items: vec![item.clone()], ..Default::default() }; + (!g.elements().is_empty()).then(|| one.stroked_bounds(g.bounding_box(), || g.clone())) +} + +/// `b` grown by how far raster effects `fx` paint past it, each spreading what the ones before it +/// paint (Rasterize at the document's raster effects resolution where it takes it). +fn spread(symbols: &SymbolBoxes, fx: &[Effect], b: Rect) -> Rect { + let ppi = doc_raster_ppi(symbols.document()); + effects::raster_effects(fx).iter().fold(b, |r, e| e.at_document_ppi(ppi).spread(r, 72.0 / ppi)) +} + +/// [`drawn_bounds`] before the raster effects of `n`'s fills and strokes: its art, and the art of +/// its brushed strokes (a symbol's brushed art as far as [`crate::brush_fx::pad`] reaches). +fn art_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { + let b = shape_bounds(symbols, n); + let brushed = vectorcraft_brush::has_brush(n) + .then(|| vectorcraft_brush::expand(symbols.document(), n)) + .flatten() + .and_then(|art| art.visual_bounds_with(symbols)); + let b = vectorcraft_geom::union_opt(b, brushed)?; + let pad = if matches!(n.kind, NodeKind::SymbolInstance { .. }) { crate::brush_fx::pad(symbols, n) } else { 0.0 }; + Some(b.inflate(pad, pad)) +} + +/// [`art_bounds`] without brush art. +fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { + // The visible members of `g` (a Shaper group's first member is its source art, which paints + // nothing). + let union = |g: &Node| { + let members = g.children()?.get(usize::from(g.extras().shaper.is_some())..).unwrap_or_default(); + members.iter().filter(|c| c.visible).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c))) + }; + match &n.kind { + _ if effects::has_container_appearance(n) => { + let art = effects::evaluate_container(n); + let art = art.as_ref().unwrap_or(n); + if art.clips() { art.visual_bounds_with(symbols) } else { union(art) } + } + NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. } => union(n), + _ if has_fx(n) => { + // A 3D effect's art. + if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) { + return drawn_bounds(symbols, &art); + } + let g = effected_path(n, &node_bezpath(n)?); + // The path as the fills and strokes without geometry effects of their own paint it + // (as every fill and stroke would when none paints). + let mut plain = n.appearance.clone(); + plain.items.retain(|i| !(paints(i) && effects::has_geometry(item_effects(i)))); + let on_path = !n.appearance.items.iter().any(paints) || plain.items.iter().any(paints); + let mut r = (on_path && !g.elements().is_empty()).then(|| plain.stroked_bounds(g.bounding_box(), || g.clone())); + let ctx = GeomContext::of(n); + for item in n.appearance.items.iter().filter(|i| paints(i) && effects::has_geometry(item_effects(i))) { + let ig = apply(item_effects(item), &g, &ctx.item(item)); + let o = if let AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 }; + r = vectorcraft_geom::union_opt(r, (!ig.elements().is_empty()).then(|| ig.bounding_box().inflate(o, o))); + } + r + } + // As [`cull_bounds_in`] measures them, without the object's own raster effects: the + // reshaped art (type's outlines measured one by one with the effects of its own fills and + // strokes), grown by how far the effects in a symbol's art and a live object's content + // paint past it. + _ if has_object_fx(n) => { + let symbol = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), + _ => None, + }; + let reshaped = effects::reshape(n, symbol.as_deref()); + let b = match &reshaped { + Some(r) if matches!(n.kind, NodeKind::Text(_)) => { + r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c))) + } + Some(r) => r.visual_bounds_with(symbols), + None => None, + } + .or_else(|| n.visual_bounds_with(symbols))?; + let a = match &reshaped { + Some(r) => reshaped_art_effects_reach(symbols, n, r), + None => art_effects_reach(symbols, n), + }; + let a = a.max(live_content_reach(symbols, n)); + Some(b.inflate(a, a)) + } + _ => { + let b = n.visual_bounds_with(symbols)?; + let a = art_effects_reach(symbols, n).max(live_content_reach(symbols, n)); + Some(b.inflate(a, a)) + } + } +} + +/// [`drawn_bounds`] of `n` grown by how far its own raster effects paint past them. With Crop +/// Marks, the [`drawn_bounds`] of its art: the marks and the object, which paints its own raster +/// effects inside the art. +pub(crate) fn painted_reach(symbols: &SymbolBoxes, n: &Node) -> Option { + if let Some(art) = effects::crop_marks_art(n) { + return drawn_bounds(symbols, &art); + } + drawn_bounds(symbols, n).map(|b| spread(symbols, &n.appearance.effects, b)) +} + +/// The outline the renderer clips `n`'s Feather, Inner Glow and Rasterize with Create Clipping +/// Mask to: a path's or compound path's geometry with its object-level geometry effects applied, +/// the outline of the art [`Renderer::fx_art`] makes for an object with object effects, and with +/// Crop Marks the object's own. For other objects it is [`effects::clip_outline`]. +pub(crate) fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> { + if let Some(art) = effects::crop_marks_art(n) { + return drawn_outline(doc, art.children()?.first()?); + } + if has_object_fx(n) { + let art = Renderer::new().fx_art(doc, &Arc::new(n.clone()), false); + return effects::clip_outline(&art); + } + match &n.kind { + NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } if effects::has_geometry(&n.appearance.effects) => { + Some((effected_path(n, &node_bezpath(n)?), *rule)) + } + _ => effects::clip_outline(n), + } +} + +/// How far raster effects `fx` paint beyond `b`, each spreading what the ones before it paint +/// ([`spread`]). +fn outset(symbols: &SymbolBoxes, fx: &[Effect], b: Rect) -> f64 { + let s = spread(symbols, fx, b); + [b.x0 - s.x0, b.y0 - s.y0, s.x1 - b.x1, s.y1 - b.y1].into_iter().fold(0.0, f64::max) +} + /// Bounds used for culling: like `Node::visual_bounds_with`, but aware of live effects anywhere in /// the subtree (a shadow can reach the view while its object is outside it). Symbol instances are /// measured by their art from `symbols`. @@ -207,7 +410,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio } else { art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c))) }?; - let o = effects::outset(&n.appearance.effects, b); + let o = outset(symbols, &n.appearance.effects, b); Some(b.inflate(o, o)) } NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { @@ -237,7 +440,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio }; let a = a.max(live_content_reach(symbols, n)); let b = b.inflate(a, a); - let o = effects::outset(&n.appearance.effects, b); + let o = outset(symbols, &n.appearance.effects, b); Some(b.inflate(o, o)) } NodeKind::SymbolInstance { .. } => { @@ -349,10 +552,43 @@ fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Color { /// cropped to the view when its effects only look nearby, else rendered at a lower resolution. const PIXEL_BUDGET: f64 = 8.4e6; +/// Most Rasterize effects of one stack that [`Renderer::raster_fx`] draws (the last ones): each +/// draws the content again inside the one after it. +const MAX_RASTERIZE: usize = 16; + +/// The columns and rows of the image Rasterize `r` makes over `rect` (whole pixels of the image, +/// `line` points wide at the document's raster effects resolution). `None` when the image would +/// hold more than [`PIXEL_BUDGET`] pixels or be wider or taller than [`crate::MAX_RASTER_SIDE`]. +fn image_size(r: &effects::Rasterize, rect: Rect, line: f64) -> Option<(u16, u16)> { + let cell = r.cell(line); + let (cols, rows) = ((rect.width() / cell).round(), (rect.height() / cell).round()); + let side = f64::from(crate::MAX_RASTER_SIDE); + (cols >= 1.0 && rows >= 1.0 && cols <= side && rows <= side && cols * rows <= PIXEL_BUDGET).then_some((cols as u16, rows as u16)) +} + +#[cfg(test)] +thread_local! { + /// How many times a Rasterize has drawn the content before it on this thread. + pub(crate) static RASTERIZED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Document Raster Effects Settings › Resolution of `doc`, in ppi (1 to 2400; 72 when it isn't a +/// number). +fn doc_raster_ppi(doc: &Document) -> f64 { + Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0) +} + /// The effects [`Renderer::pixel_content`] runs on the content's pixels (those changing the /// content itself), in stack order. fn content_changing(fx: &&RasterFx) -> bool { - matches!(fx, RasterFx::Feather { .. } | RasterFx::GaussianBlur { .. } | RasterFx::Pixel(_)) + matches!(fx, RasterFx::Feather { .. } | RasterFx::GaussianBlur { .. } | RasterFx::Pixel(_) | RasterFx::Rasterize(_)) +} + +/// Is the content drawn with hard edges for `chain` into a raster of pixels `px` wide (document +/// units): does it go first through a Rasterize with anti-aliasing off whose pixels are no +/// smaller? (Smaller ones show as the share of each raster pixel they cover, as smooth edges do.) +fn hard_edged(chain: &[&RasterFx], px: f64) -> bool { + matches!(chain.first(), Some(RasterFx::Rasterize(r)) if !r.anti_alias && r.cell(72.0) >= px) } /// How far (document units) from a pixel the content deciding it after `fx` lies; `None` when @@ -361,6 +597,7 @@ fn looks_around(fx: &RasterFx) -> Option { match fx { RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } => Some(1.5 * radius), RasterFx::Pixel(p) => p.reach(), + RasterFx::Rasterize(r) => Some(r.reach()), _ => Some(0.0), } } @@ -436,6 +673,23 @@ fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc ctx.set_transform(Affine::IDENTITY); } +/// Draw `image` with its pixels placed by `xf` (pixels to device pixels). On whole pixels at full +/// size it is drawn as [`draw_scaled`] draws it. +fn draw_placed(ctx: &mut RenderContext, image: std::sync::Arc, xf: Affine) { + let [a, b, c, d, e, f] = xf.as_coeffs(); + let near = |v: f64, to: f64| (v - to).abs() < 1e-6; + if near(a, 1.0) && near(b, 0.0) && near(c, 0.0) && near(d, 1.0) && near(e, e.round()) && near(f, f.round()) { + return draw_scaled(ctx, image, (e.round(), f.round()), 1.0); + } + let (w, h) = (image.width() as f64, image.height() as f64); + ctx.set_transform(xf); + let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Medium, ..Default::default() }; + ctx.reset_paint_transform(); + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler }); + ctx.fill_rect(&Rect::new(0.0, 0.0, w, h)); + ctx.set_transform(Affine::IDENTITY); +} + /// A cached shadow / glow raster: premultiplied, already tinted and faded, positioned relative to /// the view-space position of `anchor` (a document point). pub(crate) struct ShadowEntry { @@ -933,7 +1187,10 @@ impl Renderer { // Document-space reach of the painted geometry (strokes, arrowheads…). let outset = n.appearance.outset(); let reach = g.bounding_box().inflate(outset, outset); - let content = Content { node: n, owner: None, cache: Some(0), reach, outline: Some((g, rule)) }; + let bounds = rasterize_bounds(f.symbols, n, &rfx); + // Under Rasterize the content reaches as far as its fills' and strokes' raster effects paint. + let reach = bounds.map_or(reach, |b| reach.union(b)); + let content = Content { node: n, owner: None, cache: Some(0), reach, outline: Some((g, rule)), bounds, kept: None }; let mut items = items; self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, n, g, rule, &gctx, items.as_deref_mut())); ctx.set_transform(Affine::IDENTITY); @@ -967,12 +1224,16 @@ impl Renderer { let rfx = if effects::has_crop_marks(a) { vec![] } else { effects::raster_effects(&a.appearance.effects) }; match cull_bounds(f.symbols, &art) { Some(reach) if !f.opts.outline && !rfx.is_empty() => { - // Feather and Inner Glow clip to the art's outline. - let clipped = rfx.iter().any(|x| matches!(x, RasterFx::Feather { .. } | RasterFx::InnerGlow { .. })); + // Feather, Inner Glow and Rasterize with Create Clipping Mask clip to the art's outline. + let clipped = rfx.iter().any(|x| { + matches!(x, RasterFx::Feather { .. } | RasterFx::InnerGlow { .. }) || matches!(x, RasterFx::Rasterize(r) if r.clipping_mask) + }); let region = if clipped { self.clip_of(&art) } else { None }; // A symbol's art can change while the instance doesn't: no cached shadows. let cache = (cache && !matches!(a.kind, NodeKind::SymbolInstance { .. })).then_some(0); - let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)) }; + let bounds = rasterize_bounds(f.symbols, a, &rfx); + let reach = bounds.map_or(reach, |b| reach.union(b)); + let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)), bounds, kept: None }; self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_fx_art(c, fr, a, &art)); ctx.set_transform(Affine::IDENTITY); } @@ -1087,6 +1348,102 @@ impl Renderer { rfx: &[RasterFx], paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), ) { + // Only the last MAX_RASTERIZE Rasterize effects are drawn. + let count = rfx.iter().filter(|fx| matches!(fx, RasterFx::Rasterize(_))).count(); + let capped: Vec; + let rfx = if count > MAX_RASTERIZE { + let mut left_out = count - MAX_RASTERIZE; + capped = rfx + .iter() + .filter(|fx| { + let out = left_out > 0 && matches!(fx, RasterFx::Rasterize(_)); + left_out -= usize::from(out); + !out + }) + .cloned() + .collect(); + &capped[..] + } else { + rfx + }; + // Rasterize at the document's raster effects resolution takes it here. + let resolved: Vec; + let rfx = if rfx.iter().any(|fx| matches!(fx, RasterFx::Rasterize(r) if r.ppi.is_none())) { + let ppi = doc_raster_ppi(f.doc); + resolved = rfx.iter().map(|fx| fx.at_document_ppi(ppi)).collect(); + &resolved[..] + } else { + rfx + }; + // Rasterize makes an image of everything the effects before it paint, shadows and glows + // included, and the shadows and glows after it paint around that image. When some come + // after the last Rasterize, the stack splits after it, else when any effect comes before + // it, the stack splits before it; the effects after the split paint around the content + // drawn with those before it. Rasterize then comes first in its part. + if let Some(at) = rfx.iter().rposition(|fx| matches!(fx, RasterFx::Rasterize(_))) { + let paints_around = |fx: &[RasterFx]| fx.iter().any(|fx| !content_changing(&fx)); + let (_, rest) = rfx.split_at(at + 1); + let split = if paints_around(rest) { Some(at + 1) } else { (at > 0).then_some(at) }; + if let Some(split) = split { + let (before, after) = rfx.split_at(split); + let line = 72.0 / doc_raster_ppi(f.doc); + let (mut reach, mut spread) = (content.reach, content.bounds); + // After a Rasterize on white without a clipping mask, the silhouette is its image, + // and it stays the silhouette through the effects after it (as in `pixel_content`). + let mut image = None; + // Whether the image of a Rasterize before the split is over `rasterize_image`'s + // limits. Such an image is kept only as far as the view needs it, so the effects + // after the split are not cached. + let (mut over, mut last_over) = (false, false); + for fx in before { + if let RasterFx::Rasterize(r) = fx { + last_over = image_size(r, r.image(spread.unwrap_or(reach), line), line).is_none(); + over |= last_over; + } + let o = fx.outset(reach); + reach = reach.inflate(o, o); + spread = spread.map(|b| fx.spread(b, line)); + if let RasterFx::Rasterize(r) = fx + && r.white + && !r.clipping_mask + { + image = Some(spread.unwrap_or(reach).to_path(0.1)); + } + } + // When the split comes after the Rasterize, the shadows and glows after it and the + // content each draw the content before the split. The first draw makes the image, + // and the others draw the one it keeps. + let window = after.iter().fold(f.visible, |w, fx| { + let o = fx.outset(w); + w.inflate(o, o) + }); + let frame = last_over.then_some((f.view, f.px)); + let kept = (split > at).then(|| Kept { window, frame, image: std::cell::RefCell::new(None) }); + let inner = Content { + node: content.node, + owner: content.owner, + cache: None, + reach: content.reach, + outline: content.outline, + bounds: content.bounds, + kept: kept.as_ref(), + }; + let outline = image.as_ref().map(|g| (g, FillRule::NonZero)).or(content.outline); + // When the split comes before the Rasterize, the part from it on draws the content + // once, and its image is the one kept for a split above. + let outer = Content { + node: content.node, + owner: content.owner, + cache: content.cache.filter(|_| !over), + reach, + outline, + bounds: spread, + kept: content.kept.filter(|_| split == at), + }; + self.raster_fx(ctx, f, &outer, after, &mut |r, c, fr| r.raster_fx(c, fr, &inner, before, paint)); + return; + } + } let reach = content.reach; // The `Arc` drawn: the caller's, or the node `draw_arc` is drawing. let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node))); @@ -1128,7 +1485,7 @@ impl Renderer { // works on a coarser grid tied to the canvas, so the blurred art shifted by up to a pixel // and looked blocky as the view moved by parts of a pixel (#755). let blurs = rfx.iter().any(|fx| matches!(fx, RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } if *radius > 0.0)); - if blurs || rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_))) { + if blurs || rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_) | RasterFx::Rasterize(_))) { self.pixel_content(ctx, f, content, rfx, paint); } else { paint(self, ctx, f); @@ -1170,11 +1527,15 @@ impl Renderer { } } - /// Paint `content` through the content-changing effects of `rfx` (Gaussian Blur, Feather and the - /// Photoshop-style filters) in stack order on its pixels: rendered offscreen over all it can - /// reach, filtered on the CPU and drawn back. The raster holds at most [`PIXEL_BUDGET`] pixels: - /// larger content is cropped to the view when every effect only looks nearby, else rendered - /// at a lower resolution. A whole raster at full resolution is cached while the object, the + /// Paint `content` through the content-changing effects of `rfx` (Gaussian Blur, Feather, the + /// Photoshop-style filters and Rasterize) in stack order on its pixels: rendered offscreen over + /// all it can reach, filtered on the CPU and drawn back. A Rasterize first in the chain draws the + /// content on its image's own grid instead ([`Self::rasterize_image`]) when that image is within + /// the limits below; content with a [`Kept`] image draws that image after its first draw. The + /// raster holds at most [`PIXEL_BUDGET`] pixels and [`crate::MAX_RASTER_SIDE`] a side: content + /// over the budget is cropped to the view (content with a [`Kept`] image to its window) when + /// every effect only looks nearby, and content still over either limit is rendered at a lower + /// resolution. A whole raster at full resolution is cached while the object, the /// zoom and what the content reads ([`Inputs`]) stay the same (panning only moves it), and /// while interacting after the content only moved ([`Self::move_pixel_content`]). fn pixel_content( @@ -1186,26 +1547,42 @@ impl Renderer { paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), ) { let chain: Vec<&RasterFx> = rfx.iter().filter(content_changing).collect(); + // When the first draw of this content kept its raster, that raster is drawn. + if let Some(KeptImage::Raster(image, place)) = content.kept.and_then(|k| k.image.borrow().clone()) { + draw_placed(ctx, image, f.view * place); + return; + } + // Content kept with an image over the limits is drawn in the split's frame, over where the + // effects after the split take it from, then placed in this one. + let call = f.view; + let split_frame; + let (f, screen) = match content.kept.and_then(|k| k.frame.map(|frame| (k.window, frame))) { + Some((window, (view, px))) => { + split_frame = Frame { view, px, ..*f }; + (&split_frame, view.transform_rect_bbox(window)) + } + None => (f, Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64)), + }; // Where the result can paint: each effect spreads what the ones before it painted. let reach = chain.iter().fold(content.reach, |r, fx| { let o = fx.outset(r); r.inflate(o, o) }); let full = f.view.transform_rect_bbox(reach).inflate(2.0, 2.0); - let (sw, sh) = (ctx.width() as f64, ctx.height() as f64); - if !(full.area().is_finite() && full.x1 > 0.0 && full.y1 > 0.0 && full.x0 < sw && full.y0 < sh) { + if !(full.area().is_finite() && full.x1 > screen.x0 && full.y1 > screen.y0 && full.x0 < screen.x1 && full.y0 < screen.y1) { return; } let fits = full.area() <= PIXEL_BUDGET; // Device pixels from a pixel to the content deciding it (`None`: anywhere). let margin = chain.iter().try_fold(2.0, |m, fx| looks_around(fx).map(|d| m + d / f.px)); let region = match margin { - Some(m) if !fits => full.intersect(Rect::new(0.0, 0.0, sw, sh).inflate(m, m)), + Some(m) if !fits => full.intersect(screen.inflate(m, m)), _ => full, }; let (x0, y0) = (region.x0.floor(), region.y0.floor()); let (rw, rh) = ((region.x1 - x0).max(1.0), (region.y1 - y0).max(1.0)); - let side = u16::MAX as f64; + // (vello_cpu panics drawing near `u16::MAX` pixels a side.) + let side = f64::from(crate::MAX_RASTER_SIDE); let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0); let (w, h) = ((rw * k).ceil().clamp(1.0, side), (rh * k).ceil().clamp(1.0, side)); // The cached raster, when the whole content was rendered at full size at this zoom. @@ -1219,12 +1596,12 @@ impl Renderer { Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb, }; // De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi). - let ppi = Some(f.doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0); + let ppi = doc_raster_ppi(f.doc); // What the content reads (as the raster kept for it found it, else found now), with the - // channels the effects run on and, under De-Interlace, the resolution. + // channels the effects run on and, under De-Interlace or Rasterize, the resolution. let inputs = key.map(|key| { let reads = self.pixel_fx.get(&key).filter(|e| e.node.is(content.node)).map_or_else(|| Reads::of(content.node), |e| e.inputs.reads); - let interlaced = chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }))); + let interlaced = chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }) | RasterFx::Rasterize(_))); self.raster_inputs(f, content.node, reads).with((std::mem::discriminant(&channels), interlaced.then_some(ppi.to_bits()))) }); let interacting = f.opts.interacting; @@ -1249,14 +1626,44 @@ impl Renderer { let to_doc = view.inverse(); let px = f.px / k; let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f }; - let mut off = f.offscreen_context(wu, hu); + let line = 72.0 / ppi; let before = self.stats.moved; - self.inside_layer(|r| paint(r, &mut off, &frame)); + // A Rasterize first in the chain draws the content on its image's own grid instead, unless + // that image is too large: the content is drawn once. + let mut rasterized = match chain.first() { + Some(RasterFx::Rasterize(r)) => { + let rect = r.image(content.bounds.unwrap_or(content.reach), line); + let kept = content.kept.and_then(|k| match &*k.image.borrow() { + Some(KeptImage::Grid(image)) => Some(image.clone()), + _ => None, + }); + let image = kept.or_else(|| { + let image = self.rasterize_image(f, r, rect, paint).map(std::rc::Rc::new); + if let (Some(k), Some(image)) = (content.kept, &image) { + *k.image.borrow_mut() = Some(KeptImage::Grid(image.clone())); + } + image + }); + image.map(|image| (image, rect)) + } + _ => None, + }; + let mut pm = vello_cpu::Pixmap::new(wu, hu); + if rasterized.is_none() { + #[cfg(test)] + if matches!(chain.first(), Some(RasterFx::Rasterize(_))) { + RASTERIZED.with(|n| n.set(n.get() + 1)); + } + let mut off = f.offscreen_context(wu, hu); + if hard_edged(&chain, px) { + off.set_aliasing_threshold(crate::AntiAlias::None.threshold()); + } + self.inside_layer(|r| paint(r, &mut off, &frame)); + off.flush(); + off.render(&mut pm, &mut self.resources); + } // Content drawn over moved rasters makes a moved raster. let moved = self.stats.moved > before; - off.flush(); - let mut pm = vello_cpu::Pixmap::new(wu, hu); - off.render(&mut pm, &mut self.resources); // What the raster is made from, for its object once it moves (`move_pixel_content`). let made = key.map(|_| Made { pixels: pm.data_as_u8_slice().to_vec(), @@ -1266,18 +1673,41 @@ impl Renderer { outline: feather_outline(&chain, content, content.reach.origin()), }); let (center, extent) = (content.reach.center(), 0.5 * content.reach.width().hypot(content.reach.height())); - let space = effects::PixelSpace { to_doc, px, center, channels, line: 72.0 / ppi, extent }; + let space = effects::PixelSpace { to_doc, px, center, channels, line, extent }; let (wz, hz) = (wu as usize, hu as usize); - for fx in chain { + // What the content covers after the effects run so far: Rasterize makes its image over it. + let mut bounds = content.bounds.unwrap_or(content.reach); + // The silhouette Feather and Create Clipping Mask clip to: the content's, or after a Rasterize + // on white without a clipping mask, its image. + let mut image_outline: Option = None; + for (i, fx) in chain.into_iter().enumerate() { + let before = bounds; + bounds = fx.spread(before, space.line); + let outline = image_outline.as_ref().map(|g| (g, FillRule::NonZero)).or(content.outline); match fx { RasterFx::GaussianBlur { radius } => effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px), RasterFx::Feather { radius } => { effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px); - if let Some((g, rule)) = content.outline { + if let Some((g, rule)) = outline { self.clip_pixels(&mut pm, &frame, g, rule); } } RasterFx::Pixel(p) => p.apply(pm.data_as_u8_slice_mut(), wz, hz, &space), + RasterFx::Rasterize(r) => { + match rasterized.take().filter(|_| i == 0) { + Some((image, rect)) => { + let (pixels, cols, rows) = &*image; + r.compose(pm.data_as_u8_slice_mut(), wz, hz, &space, pixels, *cols, *rows, rect) + } + None => r.apply(pm.data_as_u8_slice_mut(), wz, hz, &space, before), + } + if let Some((g, rule)) = outline.filter(|_| r.clipping_mask) { + self.clip_pixels(&mut pm, &frame, g, rule); + } + if r.white && !r.clipping_mask { + image_outline = Some(bounds.to_path(0.1)); + } + } _ => {} } } @@ -1291,7 +1721,15 @@ impl Renderer { self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), made, inputs, moved, stamp }); self.trim_fx_rasters(FX_RASTER_BUDGET); } - draw_scaled(ctx, image, (x0, y0), k); + // An image over the limits was drawn at a frame's resolution: its raster is kept. + match content.kept.filter(|kept| kept.image.borrow().is_none()) { + Some(kept) => { + let place = f.view.inverse() * Affine::translate((x0, y0)) * Affine::scale(1.0 / k); + *kept.image.borrow_mut() = Some(KeptImage::Raster(image.clone(), place)); + if f.view == call { draw_scaled(ctx, image, (x0, y0), k) } else { draw_placed(ctx, image, call * place) } + } + None => draw_scaled(ctx, image, (x0, y0), k), + } } /// While interacting, give `content` (cache key `key`) the filtered raster an earlier frame @@ -1301,7 +1739,7 @@ impl Renderer { /// (and the same Feather outline against its anchor), and whose pixels are the pixels of /// `content` drawn into a raster placed as that one was against its anchor. The raster is kept /// as `content`'s, marked moved, and is drawn at the nearest whole pixel. Content under - /// De-Interlace takes none. + /// De-Interlace, or under a Rasterize first in the chain, takes none. #[allow(clippy::too_many_arguments)] fn move_pixel_content( &mut self, @@ -1313,7 +1751,8 @@ impl Renderer { inputs: &Inputs, paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), ) { - if chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }))) { + if chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }))) || matches!(chain.first(), Some(RasterFx::Rasterize(_))) + { return; } let (linear, stamp, anchor) = (linear_key(f.view), self.stamp, content.reach.origin()); @@ -1337,6 +1776,9 @@ impl Renderer { let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); let frame = Frame { mt: false, view, visible, ..*f }; let mut off = f.offscreen_context(w, h); + if hard_edged(chain, f.px) { + off.set_aliasing_threshold(crate::AntiAlias::None.threshold()); + } self.inside_layer(|r| paint(r, &mut off, &frame)); off.flush(); let mut pm = vello_cpu::Pixmap::new(w, h); @@ -1352,17 +1794,19 @@ impl Renderer { } /// The inputs a raster of `n`'s content, which reads `reads`, is drawn with in frame `f`: the - /// color settings, the edge smoothing, the knockout `n` inherits and, while `n` holds the - /// isolated group, isolation mode; the pattern definitions, symbols and brush library it - /// reads; for images and placed documents their pixels and files, the layer's Dim Images and - /// the options they are drawn with; for type the fonts, whether overprinting shows - /// ([`crate::proof::overprints`]) and Document Setup's substitution highlights; and the - /// plug-ins installed, when an object in it has a plug-in effect, and when it shows symbol or - /// pattern art and an object in the document's symbol or pattern art has one. + /// color settings, the edge smoothing, the document's raster effects resolution (which a + /// Rasterize or De-Interlace anywhere in the content follows), the knockout `n` inherits and, + /// while `n` holds the isolated group, isolation mode; the pattern definitions, symbols and + /// brush library it reads; for images and placed documents their pixels and files, the + /// layer's Dim Images and the options they are drawn with; for type the fonts, whether + /// overprinting shows ([`crate::proof::overprints`]) and Document Setup's substitution + /// highlights; and the plug-ins installed, when an object in it has a plug-in effect, and + /// when it shows symbol or pattern art and an object in the document's symbol or pattern art + /// has one. pub(crate) fn raster_inputs(&mut self, f: &Frame, n: &Node, reads: Reads) -> Inputs { let doc = self.doc_inputs(f.doc); let mut h = DefaultHasher::new(); - (doc.cms, f.opts.anti_alias.threshold(), self.knockout).hash(&mut h); + (doc.cms, f.opts.anti_alias.threshold(), self.knockout, f.doc.raster_effects_ppi.to_bits()).hash(&mut h); if let Some((isolated, path)) = self.isolation_path(f) && n.id != isolated && path.contains(&n.id) @@ -1455,6 +1899,42 @@ impl Renderer { } } + /// The image Effect › Rasterize `r` makes of the content `paint` draws over `rect` (whole + /// pixels of the image): drawn on the image's own grid as Object › Rasterize draws it (with + /// hard edges when `r` doesn't anti-alias), then given `r`'s background and color model. `None` + /// when it would hold more than [`PIXEL_BUDGET`] pixels or be wider or taller than + /// [`crate::MAX_RASTER_SIDE`]. → (pixels, columns, rows) + fn rasterize_image( + &mut self, + f: &Frame, + r: &effects::Rasterize, + rect: Rect, + paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), + ) -> Option<(Vec, usize, usize)> { + let line = 72.0 / doc_raster_ppi(f.doc); + let (w, h) = image_size(r, rect, line)?; + #[cfg(test)] + RASTERIZED.with(|n| n.set(n.get() + 1)); + let cell = r.cell(line); + let view = Affine::scale(1.0 / cell) * Affine::translate(-rect.origin().to_vec2()); + // The effect's own anti-aliasing, whatever the view or the export draws with. + let anti_alias = match (r.anti_alias, r.hinted) { + (false, _) => crate::AntiAlias::None, + (true, true) => crate::AntiAlias::Type, + (true, false) => crate::AntiAlias::Art, + }; + let opts = crate::RenderOptions { anti_alias, ..f.opts.clone() }; + let frame = Frame { mt: false, view, visible: rect, px: cell, opts: &opts, ..*f }; + let mut off = frame.offscreen_context(w, h); + self.inside_layer(|s| paint(s, &mut off, &frame)); + off.flush(); + let mut pm = vello_cpu::Pixmap::new(w, h); + off.render(&mut pm, &mut self.resources); + let mut pixels = pm.data_as_u8_slice().to_vec(); + r.finish(&mut pixels); + Some((pixels, usize::from(w), usize::from(h))) + } + /// Keep `pm`'s pixels only inside `g` filled by `rule` as `frame` draws it (Feather's clip). fn clip_pixels(&mut self, pm: &mut vello_cpu::Pixmap, frame: &Frame, g: &BezPath, rule: FillRule) { let mut m = frame.offscreen_context(pm.width(), pm.height()); @@ -1553,8 +2033,17 @@ impl Renderer { let outset = n.appearance.outset(); let orule = if item.is_fill() { rule } else { FillRule::NonZero }; // Each item's shadows get their own cache slots. - let content = - Content { node: n, owner: None, cache: Some((j + 1) << 8), reach: ib.inflate(outset, outset), outline: Some((&outline, orule)) }; + // Rasterize makes its image over the item's silhouette. + let bounds = rfx.iter().any(|fx| matches!(fx, RasterFx::Rasterize(_))).then(|| outline.bounding_box()); + let content = Content { + node: n, + owner: None, + cache: Some((j + 1) << 8), + reach: ib.inflate(outset, outset), + outline: Some((&outline, orule)), + bounds, + kept: None, + }; self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_item(c, fr, n, item, ig, rule, ib)); ctx.set_transform(Affine::IDENTITY); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 556e91693..b2639af5a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -64,6 +64,19 @@ pub fn effect_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { } } +/// Bounds of what `n` draws with its own and its members' geometry effects applied and with the +/// reach of its own and its members' raster effects, measured as [`Node::visual_bounds_with`] +/// measures art without effects: what Object › Rasterize makes its image over. +pub fn drawn_bounds(doc: &Document, n: &Node) -> Option { + fx::painted_reach(&doc.symbol_boxes(), n) +} + +/// The outline, with `n`'s geometry effects applied, that the canvas clips `n`'s Rasterize with +/// Create Clipping Mask to. Object › Rasterize's clipping mask follows it. +pub fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> { + fx::drawn_outline(doc, n) +} + /// Largest raster an export may ask [`Renderer::render_region`] for, per side. The CPU rasteriser /// addresses at most `u16::MAX` pixels per side (and panics at that edge). pub const MAX_RASTER_SIDE: u32 = 32_768; @@ -2395,6 +2408,8 @@ mod tests_objectfx; #[cfg(test)] mod tests_placed_document; #[cfg(test)] +mod tests_rasterize; +#[cfg(test)] mod tests_setup; #[cfg(test)] mod tests_strokegradient; diff --git a/crates/render/src/tests_rasterize.rs b/crates/render/src/tests_rasterize.rs new file mode 100644 index 000000000..d317cb507 --- /dev/null +++ b/crates/render/src/tests_rasterize.rs @@ -0,0 +1,423 @@ +//! Effect › Rasterize on the canvas: the art drawn as square pixels of the effect's resolution +//! over its bounds and the room around them, on white or on nothing, its paths left as they are. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Effect, Node}; +use vectorcraft_geom::{Anchor, PathData, Point, SubPath, shapes}; + +use super::*; + +/// A red shape (`path`, over 10..30 pt) with the effects `fx` (id, params), in a document whose +/// raster effects resolution is `ppi`. +fn doc(path: PathData, fx: &[(&str, Value)], ppi: f64) -> Document { + let mut d = Document::new(100.0, 100.0); + d.raster_effects_ppi = ppi; + let l = d.layers[0].id; + let mut n = Node::path(d.alloc_id(), path, Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0)); + n.appearance.effects = fx.iter().map(|(id, params)| Effect { id: (*id).into(), params: params.clone(), visible: true }).collect(); + d.insert(Some(l), 0, n).unwrap(); + d +} + +/// A right triangle over 10..30 pt whose long side runs diagonally. +fn triangle() -> PathData { + let corner = |x, y| Anchor::corner(Point::new(x, y)); + PathData::single(SubPath::new(vec![corner(10.0, 10.0), corner(30.0, 10.0), corner(10.0, 30.0)], true)) +} + +/// `d` drawn at `zoom` device pixels a point on a transparent canvas `size` pixels square. +fn draw(d: &Document, zoom: f64, size: u32) -> Rendered { + Renderer::new().render(d, size, size, Affine::scale(zoom), &RenderOptions { background: None, artboards: false, ..Default::default() }) +} + +fn pixel(r: &Rendered, x: usize, y: usize) -> [u8; 4] { + let i = (y * r.width as usize + x) * 4; + [r.pixels[i], r.pixels[i + 1], r.pixels[i + 2], r.pixels[i + 3]] +} + +/// Whether each of the `n` × `n` squares `side` device pixels wide from (`at`, `at`) is one color. +fn squares_are_flat(r: &Rendered, at: usize, side: usize, n: usize) -> bool { + (0..n * n).all(|k| { + let (x0, y0) = (at + (k % n) * side, at + (k / n) * side); + let first = pixel(r, x0, y0); + (0..side * side).all(|i| pixel(r, x0 + i % side, y0 + i / side) == first) + }) +} + +#[test] +fn rasterize_draws_the_art_in_pixels_of_its_resolution() { + // (params, the document's resolution, device pixels a point, device pixels an image pixel) + let cases = [ + (json!({"useDocumentResolution": false, "resolution": 72}), 300.0, 10.0, 10), + (json!({"useDocumentResolution": false, "resolution": 72}), 300.0, 2.0, 2), + (json!({"useDocumentResolution": false, "resolution": 300}), 72.0, 25.0, 6), + (json!({}), 150.0, 25.0, 12), + ]; + for (params, ppi, zoom, side) in cases { + let at = (10.0 * zoom) as usize; + // The whole image pixels inside the 20 pt triangle's bounds. + let n = (20.0 * zoom) as usize / side; + let canvas = (40.0 * zoom) as u32; + let rasterized = draw(&doc(triangle(), &[("rasterize", params.clone())], ppi), zoom, canvas); + assert!(squares_are_flat(&rasterized, at, side, n), "{params}"); + // Along the diagonal an image pixel takes the mean of the art it covers. + let partly = (0..n).map(|k| pixel(&rasterized, at + k * side, at + (n - 1 - k) * side)[3]).filter(|a| *a > 0 && *a < 255).count(); + assert!(partly > n / 2, "{params}: {partly} of {n}"); + // Without the effect the diagonal edge runs through those squares. + assert!(!squares_are_flat(&draw(&doc(triangle(), &[], ppi), zoom, canvas), at, side, n)); + } +} + +#[test] +fn rasterize_is_drawn_where_only_its_image_reaches_the_view() { + // At 1 ppi a pixel of the image is 72 pt wide: the 20 pt square's image runs to 82 pt. + let d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[("rasterize", json!({"background": "white"}))], 1.0); + // A view of 40..80 pt across, where only the image's white reaches. + let opts = RenderOptions { background: None, artboards: false, ..Default::default() }; + let r = Renderer::new().render(&d, 40, 40, Affine::translate((-40.0, 0.0)), &opts); + assert_eq!(pixel(&r, 20, 20)[3], 255); + // A blur of 4 pt (6 pt of spread) and 10 pt of white around it: the white reaches 46 pt. + let fx = [("blur.gaussian", json!({"radius": 4})), ("rasterize", json!({"background": "white", "addAround": 10}))]; + let d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &fx, 72.0); + let r = Renderer::new().render(&d, 40, 40, Affine::translate((-45.0, 0.0)), &opts); + assert_eq!(pixel(&r, 0, 20), [255; 4]); + // A fill's own Rasterize with 100 pt of white around: the white reaches 130 pt. + let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[], 72.0); + let l = d.layers[0].id; + let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]); + n.appearance.items[0].effects_mut().push(Effect { + id: "rasterize".into(), + params: json!({"background": "white", "addAround": 100}), + visible: true, + }); + let r = Renderer::new().render(&d, 40, 40, Affine::translate((-115.0, 0.0)), &opts); + assert_eq!(pixel(&r, 5, 20), [255; 4]); +} + +#[test] +fn art_that_paints_nothing_takes_no_room_in_the_image() { + // A second fill, hidden, under a Gaussian Blur that would spread 15 pt. + let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[("rasterize", json!({"background": "white"}))], 72.0); + let l = d.layers[0].id; + let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]); + let mut hidden = n.appearance.items[0].clone(); + if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut hidden { + f.visible = false; + f.effects.push(Effect { id: "blur.gaussian".into(), params: json!({"radius": 10}), visible: true }); + } + n.appearance.items.push(hidden.clone()); + // A third, hidden too, offset 5 pt outward. + if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut hidden { + f.effects = vec![Effect { id: "path.offsetPath".into(), params: json!({"offset": 5}), visible: true }]; + } + n.appearance.items.push(hidden); + let r = draw(&d, 10.0, 400); + assert_eq!((pixel(&r, 105, 105), pixel(&r, 95, 200)[3]), ([255, 0, 0, 255], 0)); + // Nor does a hidden member of a group. + let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[], 72.0); + let mut away = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(34.0, 10.0, 38.0, 30.0)), Appearance::default()); + away.visible = false; + let l = d.layers[0].id; + let mut members = d.children_mut(Some(l)).map(std::mem::take).unwrap(); + members.push(Arc::new(away)); + let mut group = Node::group(d.alloc_id(), members); + group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({"background": "white"}), visible: true }); + d.insert(Some(l), 0, group).unwrap(); + assert_eq!(pixel(&draw(&d, 10.0, 400), 350, 200)[3], 0); + // Nor does a Shaper group's source art, its first member, which paints nothing. + let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[], 72.0); + let none = Appearance::basic(Paint::None, Paint::None, 0.0); + let source = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(34.0, 10.0, 38.0, 30.0)), none); + let l = d.layers[0].id; + let mut members = d.children_mut(Some(l)).map(std::mem::take).unwrap(); + members.insert(0, Arc::new(source)); + let mut group = Node::group(d.alloc_id(), members); + group.extras_mut().shaper = Some(Box::new(vectorcraft_doc::shaper::ShaperSpec { faces: vec![], edges: vec![] })); + group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({"background": "white"}), visible: true }); + d.insert(Some(l), 0, group).unwrap(); + assert_eq!(pixel(&draw(&d, 10.0, 400), 350, 200)[3], 0); + // Nor does the path where its only fill's own Transform effect moves the fill away from it. + let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[("rasterize", json!({"background": "white"}))], 72.0); + let l = d.layers[0].id; + let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]); + if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut n.appearance.items[0] { + f.effects.push(Effect { id: "distort.transform".into(), params: json!({"moveH": 40}), visible: true }); + } + let r = draw(&d, 10.0, 800); + assert_eq!((pixel(&r, 600, 200), pixel(&r, 200, 200)[3]), ([255, 0, 0, 255], 0)); +} + +#[test] +fn rasterize_covers_the_share_of_a_screen_pixel_its_image_covers() { + // A 1 pt square from x = 10.25 at 72 ppi and 100%: its image pixel covers a quarter of + // screen column 11, on white and on nothing. + let square = || shapes::rectangle(Rect::new(10.25, 10.0, 11.25, 11.0)); + for (params, want) in [(json!({}), [64, 0, 0, 64]), (json!({"background": "white"}), [64, 0, 0, 64])] { + let r = draw(&doc(square(), &[("rasterize", params.clone())], 72.0), 1.0, 20); + assert_eq!(pixel(&r, 11, 10), want, "{params}"); + assert_eq!(pixel(&r, 12, 10)[3], 0, "{params}"); + } +} + +#[test] +fn objects_wider_than_a_raster_can_be_are_drawn_with_their_pixel_effects() { + // 65,530 pt across at 100%: wider than the rasters the effects are made in can safely be. + let bar = || shapes::rectangle(Rect::new(0.0, 10.0, 65_530.0, 11.0)); + let opts = RenderOptions { background: None, artboards: false, ..Default::default() }; + for fx in [("rasterize", json!({"background": "white"})), ("blur.gaussian", json!({"radius": 1}))] { + let r = Renderer::new().render(&doc(bar(), std::slice::from_ref(&fx), 72.0), 40, 20, Affine::translate((-65_500.0, 0.0)), &opts); + assert!(pixel(&r, 10, 10)[3] > 0, "{}", fx.0); + } +} + +#[test] +fn a_stack_of_ten_thousand_rasterize_effects_is_drawn() { + let fx = vec![("rasterize", json!({"background": "white"})); 10_000]; + let r = draw(&doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &fx, 72.0), 1.0, 40); + assert_eq!(pixel(&r, 20, 20), [255, 0, 0, 255]); +} + +#[test] +fn a_stack_of_rasterize_effects_with_shadows_draws_the_content_of_each_once() { + let shadow = ("stylize.dropShadow", json!({"mode": "normal", "opacity": 100, "x": 2, "y": 2, "blur": 1, "color": "#0000ff"})); + let glow = ("stylize.outerGlow", json!({"mode": "normal", "opacity": 100, "blur": 2, "color": "#00ff00"})); + // Five Rasterize effects, each followed by a drop shadow and an outer glow: on a 20 pt square + // at 72 ppi, and on a 300 pt square at 2,400 ppi, whose images are too large for the canvas to + // draw on their own grid. + for (ppi, side, zoom) in [(72.0, 20.0, 1.0), (2400.0, 300.0, 0.1)] { + let rasterize = ("rasterize", json!({"useDocumentResolution": false, "resolution": ppi})); + let fx: Vec<_> = (0..5).flat_map(|_| [rasterize.clone(), shadow.clone(), glow.clone()]).collect(); + let d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 10.0 + side, 10.0 + side)), &fx, 72.0); + let drawn = || crate::fx::RASTERIZED.with(std::cell::Cell::get); + let before = drawn(); + let r = draw(&d, zoom, 40); + assert_eq!(drawn() - before, 5, "{ppi} ppi"); + assert_eq!(pixel(&r, 15, 15), [255, 0, 0, 255], "{ppi} ppi"); + } +} + +#[test] +fn a_new_document_resolution_redraws_a_rasterized_member_of_a_blurred_group() { + let mut d = doc(triangle(), &[("rasterize", json!({}))], 72.0); + let l = d.layers[0].id; + let members = d.children_mut(Some(l)).map(std::mem::take).unwrap(); + let mut group = Node::group(d.alloc_id(), members); + group.appearance.effects.push(Effect { id: "blur.gaussian".into(), params: json!({"radius": 0.5}), visible: true }); + d.insert(Some(l), 0, group).unwrap(); + let opts = RenderOptions { background: None, artboards: false, ..Default::default() }; + let mut r = Renderer::new(); + r.render(&d, 400, 400, Affine::scale(10.0), &opts); + d.raster_effects_ppi = 18.0; + let again = r.render(&d, 400, 400, Affine::scale(10.0), &opts); + assert_eq!(again.pixels, Renderer::new().render(&d, 400, 400, Affine::scale(10.0), &opts).pixels); +} + +#[test] +fn an_export_draws_the_image_with_the_effects_own_anti_aliasing() { + // A supersampled Art Optimized export draws its art with hard edges and then averages them; + // the effect's image keeps its smooth diagonal. + let d = doc(triangle(), &[("rasterize", json!({"useDocumentResolution": false, "resolution": 72}))], 72.0); + let opts = RenderOptions { background: None, artboards: false, anti_alias: AntiAlias::Art, supersample: true, ..Default::default() }; + let r = Renderer::new().render_region_with(&d, Rect::new(0.0, 0.0, 40.0, 40.0), 10.0, &opts); + let partly = (0..20).map(|k| pixel(&r, 105 + k * 10, 105 + (19 - k) * 10)[3]).filter(|a| *a > 0 && *a < 255).count(); + assert!(partly > 10, "{partly} of 20"); +} + +#[test] +fn rasterize_without_anti_aliasing_has_hard_edges_at_any_zoom() { + let fx = [("rasterize", json!({"useDocumentResolution": false, "resolution": 72, "antiAlias": "none"}))]; + for zoom in [10.0, 1.0] { + let r = draw(&doc(triangle(), &fx, 72.0), zoom, (40.0 * zoom) as u32); + let alphas: Vec = r.pixels.chunks(4).map(|p| p[3]).collect(); + assert!(alphas.iter().all(|a| *a == 0 || *a == 255), "zoom {zoom}"); + assert!(alphas.contains(&255)); + } + // A bar 0.6 pt wide covers the center of the image pixel it starts, which it then fills, and + // which at 50% covers half a screen pixel: it shows there, partly covering it. + let bar = || shapes::rectangle(Rect::new(10.3, 10.0, 10.9, 30.0)); + assert_eq!(pixel(&draw(&doc(bar(), &fx, 72.0), 10.0, 400), 105, 200)[3], 255); + let half = pixel(&draw(&doc(bar(), &fx, 72.0), 0.5, 20), 5, 10)[3]; + assert!(half > 0 && half < 255, "{half}"); + // At 100% that image pixel, 10.3..11.3 pt, covers 70% of screen column 10 and 30% of column 11. + let full = draw(&doc(bar(), &fx, 72.0), 1.0, 40); + assert_eq!((pixel(&full, 10, 20)[3], pixel(&full, 11, 20)[3]), (178, 77)); +} + +#[test] +fn rasterize_keeps_what_an_earlier_effect_spreads_beyond_the_bounds() { + let fx = [("blur.gaussian", json!({"radius": 4})), ("rasterize", json!({"useDocumentResolution": false, "resolution": 72}))]; + let r = draw(&doc(triangle(), &fx, 72.0), 10.0, 400); + // 1.5 pt left of the triangle's bounds, the blur still shows. + assert!(pixel(&r, 85, 150)[3] > 0); + // So does a blur of the fill's own. + let mut d = doc(triangle(), &fx[1..], 72.0); + let l = d.layers[0].id; + let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]); + n.appearance.items[0].effects_mut().push(Effect { id: "blur.gaussian".into(), params: json!({"radius": 4}), visible: true }); + assert!(pixel(&draw(&d, 10.0, 400), 85, 150)[3] > 0); + // A fill's own Add Around that stays inside a 40 pt stroke takes no room past the stroke. + let mut d = doc(shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0)), &[("rasterize", json!({"background": "white"}))], 72.0); + let l = d.layers[0].id; + let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]); + n.appearance.items[0].effects_mut().push(Effect { id: "rasterize".into(), params: json!({"addAround": 10}), visible: true }); + n.appearance.items[1] = vectorcraft_doc::AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 40.0)); + let r = draw(&d, 2.0, 200); + assert_eq!((pixel(&r, 156, 100)[3], pixel(&r, 170, 100)[3]), (255, 0)); +} + +#[test] +fn rasterize_and_shadows_paint_in_stack_order() { + let square = || shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)); + let shadow = ("stylize.dropShadow", json!({"mode": "normal", "opacity": 100, "x": 5, "y": 5, "blur": 0, "color": "#0000ff"})); + let gray = ("rasterize", json!({"colorModel": "grayscale"})); + // Below the square's corner, only the shadow paints. + let first = draw(&doc(square(), &[shadow.clone(), gray.clone()], 72.0), 10.0, 400); + let [r, g, b, a] = pixel(&first, 330, 330); + assert!(a == 255 && r == g && g == b, "the shadow is rasterized in grayscale: {:?}", [r, g, b, a]); + let after = draw(&doc(square(), &[gray.clone(), shadow.clone()], 72.0), 10.0, 400); + assert_eq!(pixel(&after, 330, 330), [0, 0, 255, 255], "a shadow after Rasterize keeps its color"); + // A shadow after Rasterize is the image's: a circle's square image on white casts a square shadow. + let circle = shapes::ellipse(Rect::new(10.0, 10.0, 30.0, 30.0)); + let cast = draw(&doc(circle, &[("rasterize", json!({"background": "white"})), shadow.clone()], 72.0), 10.0, 400); + assert_eq!(pixel(&cast, 320, 160), [0, 0, 255, 255]); + // Between two Rasterize effects, the second one takes it in. + let between = draw(&doc(square(), &[("rasterize", json!({})), shadow.clone(), gray], 72.0), 10.0, 400); + let [r, g, b, _] = pixel(&between, 330, 330); + assert!(r == g && g == b && b > 0, "{:?}", [r, g, b]); + // A member's shadow is part of a group's art. + let mut d = doc(square(), std::slice::from_ref(&shadow), 72.0); + let l = d.layers[0].id; + let members = d.children_mut(Some(l)).map(std::mem::take).unwrap(); + let mut group = Node::group(d.alloc_id(), members); + group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true }); + d.insert(Some(l), 0, group).unwrap(); + assert_eq!(pixel(&draw(&d, 10.0, 400), 330, 330), [0, 0, 255, 255]); + // So are a member's Crop Marks: the top right mark runs from x 39 to 57 at y 10. + let mut d = doc(square(), &[("cropMarks", json!({}))], 72.0); + let l = d.layers[0].id; + let members = d.children_mut(Some(l)).map(std::mem::take).unwrap(); + let mut group = Node::group(d.alloc_id(), members); + group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true }); + d.insert(Some(l), 0, group).unwrap(); + let r = draw(&d, 10.0, 400); + assert!((95..105).any(|y| pixel(&r, 395, y)[3] > 0)); + // So is the shadow in a symbol's art, for its instance. + let mut d = Document::new(100.0, 100.0); + let mut art = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + art.appearance.effects.push(Effect { id: shadow.0.into(), params: shadow.1.clone(), visible: true }); + d.symbols.push(vectorcraft_doc::Symbol { name: "Shadowed".into(), art: Arc::new(art) }); + let mut instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Shadowed".into(), xf: Affine::translate((20.0, 20.0)) }); + instance.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true }); + let l = d.layers[0].id; + d.insert(Some(l), 0, instance).unwrap(); + assert_eq!(pixel(&draw(&d, 10.0, 400), 330, 330), [0, 0, 255, 255]); +} + +#[test] +fn rasterize_takes_in_the_art_of_a_3d_effect() { + let square = || shapes::rectangle(Rect::new(30.0, 30.0, 50.0, 50.0)); + let extrude = ("threeD.extrude", json!({})); + let solid = draw(&doc(square(), std::slice::from_ref(&extrude), 72.0), 10.0, 800); + // Pixels the extruded art covers outside the square. + let outside: Vec<(usize, usize)> = (0..800 * 800) + .map(|i| (i % 800, i / 800)) + .filter(|(x, y)| !((300..500).contains(x) && (300..500).contains(y)) && pixel(&solid, *x, *y)[3] == 255) + .collect(); + assert!(!outside.is_empty()); + let rasterized = draw(&doc(square(), &[extrude, ("rasterize", json!({}))], 72.0), 10.0, 800); + assert!(outside.iter().all(|(x, y)| pixel(&rasterized, *x, *y)[3] > 0)); +} + +#[test] +fn rasterize_takes_in_brush_art_wider_than_the_stroke() { + // A 1 pt line under the 10 pt. Oval brush, which paints about 2 pt from its center line. + let mut d = Document::new(200.0, 100.0); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + ap.stroke_mut().unwrap().brush = Some("10 pt. Oval".into()); + let mut n = Node::path(d.alloc_id(), shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()), ap); + n.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true }); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let r = Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &RenderOptions { background: None, artboards: false, ..Default::default() }); + assert!(pixel(&r, 100, 52)[3] > 128, "{:?}", pixel(&r, 100, 52)); +} + +#[test] +fn rasterize_takes_in_type_that_its_own_fills_effects_move() { + // "IIII" is set at 40 pt from x 5 pt with a red fill of its own under the characters, and a + // Transform effect on that fill moves it 100 pt to the right. + let typed = |fx: &[Effect]| { + let mut d = Document::new(200.0, 100.0); + let style = vectorcraft_doc::text::CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; + let text = vectorcraft_doc::text::TextObject::point(Point::new(5.0, 60.0), "IIII", style); + let mut n = Node::new(d.alloc_id(), vectorcraft_doc::NodeKind::Text(Box::new(text))); + let mut fill = vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + fill.effects.push(Effect { id: "distort.transform".into(), params: json!({"moveH": 100}), visible: true }); + n.appearance.items.push(vectorcraft_doc::AppearanceItem::Fill(fill)); + n.appearance.set_contents_at(1); + n.appearance.effects = fx.to_vec(); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &RenderOptions { background: None, artboards: false, ..Default::default() }) + }; + // The pixels right of x 100 that the moved fill paints. + let moved = |r: &Rendered| (0..200 * 100).filter(|i| i % 200 >= 100 && pixel(r, i % 200, i / 200)[0] > 128).count(); + assert!(moved(&typed(&[])) > 50); + let rasterize = Effect { id: "rasterize".into(), params: json!({"useDocumentResolution": false, "resolution": 72}), visible: true }; + assert!(moved(&typed(&[rasterize])) > 50); +} + +#[test] +fn rasterize_fills_its_area_on_white_and_add_around_grows_it() { + let circle = || shapes::ellipse(Rect::new(10.0, 10.0, 30.0, 30.0)); + let at = |params: Value| draw(&doc(circle(), &[("rasterize", params)], 72.0), 10.0, 400); + let white = at(json!({"background": "white"})); + // Inside the bounds, outside the circle: white; outside the bounds: nothing. + assert_eq!((pixel(&white, 105, 105), pixel(&white, 200, 200)), ([255; 4], [255, 0, 0, 255])); + assert_eq!(pixel(&white, 95, 200)[3], 0); + let clear = at(json!({"background": "transparent"})); + assert_eq!((pixel(&clear, 105, 105)[3], pixel(&clear, 200, 200)), (0, [255, 0, 0, 255])); + // Add Around Object: 2 pt more on every side. + let around = at(json!({"background": "white", "addAround": 2})); + assert_eq!((pixel(&around, 85, 200), pixel(&around, 75, 200)[3]), ([255; 4], 0)); + // A second Rasterize makes its image over the first one's whole pixels, no larger. + let twice = draw(&doc(circle(), &[("rasterize", json!({})), ("rasterize", json!({"background": "white"}))], 72.0), 10.0, 400); + assert_eq!((pixel(&twice, 105, 105), pixel(&twice, 95, 200)[3], pixel(&twice, 305, 200)[3]), ([255; 4], 0, 0)); + // A Feather or an Inner Glow after a Rasterize on white works on the image's square edge. + let white = ("rasterize", json!({"background": "white"})); + let feathered = draw(&doc(circle(), &[white.clone(), ("stylize.feather", json!({"radius": 1}))], 72.0), 10.0, 400); + assert!(pixel(&feathered, 106, 106)[3] > 0); + let glow = ("stylize.innerGlow", json!({"mode": "normal", "opacity": 100, "blur": 2, "color": "#ff0000", "source": "edge"})); + let glowing = draw(&doc(circle(), &[white.clone(), glow.clone()], 72.0), 10.0, 400); + assert!(pixel(&glowing, 102, 102)[1] < 200, "{:?}", pixel(&glowing, 102, 102)); + // With a Rasterize on nothing between them, the glow follows the same edge. + let glowing = draw(&doc(circle(), &[white, ("rasterize", json!({})), glow], 72.0), 10.0, 400); + assert!(pixel(&glowing, 102, 102)[1] < 200, "{:?}", pixel(&glowing, 102, 102)); + // Create Clipping Mask: the white stays inside the circle. + let clipped = at(json!({"background": "white", "clippingMask": true})); + assert_eq!((pixel(&clipped, 105, 105)[3], pixel(&clipped, 200, 200)), (0, [255, 0, 0, 255])); + // Inside the outline the white shows where the art paints nothing: a circle stroked only. + let mut ring = doc(circle(), &[], 72.0); + let l = ring.layers[0].id; + let n = Arc::make_mut(&mut ring.children_mut(Some(l)).unwrap()[0]); + n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0).into(); + n.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({"background": "white", "clippingMask": true}), visible: true }); + let ring = draw(&ring, 10.0, 400); + assert_eq!((pixel(&ring, 200, 200), pixel(&ring, 105, 105)[3]), ([255; 4], 0)); + // A stroke 1 pt wide widens the image by half a point a side, as it widens the visual bounds. + let mut stroked = doc(circle(), &[], 72.0); + let n = Arc::make_mut(&mut stroked.children_mut(Some(l)).unwrap()[0]); + n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0).into(); + n.appearance.effects.push(Effect { + id: "rasterize".into(), + params: json!({"background": "white", "resolution": 720, "useDocumentResolution": false}), + visible: true, + }); + let stroked = draw(&stroked, 10.0, 400); + assert_eq!((pixel(&stroked, 96, 105), pixel(&stroked, 93, 105)[3]), ([255; 4], 0)); +} diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index aef875a40..cebea6d34 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -591,7 +591,7 @@ impl Rules<'_> { } RasterFx::GaussianBlur { radius } => filter = Some(format!("blur({})", self.len(radius / 2.0))), RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => art.unsupported("inner glows or feathering"), - RasterFx::Pixel(_) => art.unsupported("pixel effects"), + RasterFx::Pixel(_) | RasterFx::Rasterize(_) => art.unsupported("pixel effects"), } } if !shadows.is_empty() { diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index b51dffe43..412d63c94 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1617,7 +1617,7 @@ impl Writer<'_> { let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach)); let mut opened = 0; for f in fx.iter().rev() { - if matches!(f, RasterFx::Pixel(_)) { + if matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_)) { self.warn("SVG has no pixel effects such as Radial Blur, Smart Blur or Unsharp Mask: they are left out"); continue; } @@ -1678,7 +1678,7 @@ impl Writer<'_> { sd(*radius) ), RasterFx::GaussianBlur { radius } => format!("", sd(*radius)), - RasterFx::Pixel(_) => continue, + RasterFx::Pixel(_) | RasterFx::Rasterize(_) => continue, }; // Filters composite normally: a shadow's or glow's other blend mode is recorded for import. let mode = match f { diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index c103da554..32003305f 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -158,6 +158,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { free_distort::fields(app, ui, d); values_changed(d) } + None if id == vectorcraft_effects::RASTERIZE => { + super::raster_effects::effect_fields(app, ui, d); + values_changed(d) + } None => { let doc = vectorcraft_effects::effect_info(&id).map(|e| e.params).unwrap_or_default(); let unit = app.session.general_unit(); diff --git a/crates/ui-egui/src/dialogs/raster_effects.rs b/crates/ui-egui/src/dialogs/raster_effects.rs index b24a293ca..b21e284ee 100644 --- a/crates/ui-egui/src/dialogs/raster_effects.rs +++ b/crates/ui-egui/src/dialogs/raster_effects.rs @@ -2,7 +2,8 @@ //! anti-alias, clipping mask, room around the art and spot colour options. //! //! Fields: what `document.rasterEffectsSettings` reports (and takes back). OK runs it with them: -//! one undo step. On an error the dialog stays open. +//! one undo step. On an error the dialog stays open. [`effect_fields`] lays out the options of +//! Effect › Rasterize… with the same widgets. use serde_json::{Value, json}; @@ -94,6 +95,67 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// The options of Effect › Rasterize… in its effect dialog, with this dialog's widgets: Color +/// Model, Resolution (a preset, Use Document Raster Effects Resolution or Other) and Background, +/// then an Anti-aliasing menu, Create Clipping Mask and Add … Around Object. +pub(super) fn effect_fields(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + let own = if app.session.active().is_some_and(|s| s.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk) { "CMYK" } else { "RGB" }; + choice(ui, d, "colorModel", tl!("Color Model:"), &[("document", own), ("grayscale", tl!("Grayscale")), ("bitmap", tl!("Bitmap"))]); + ui.add_space(4.0); + let (ppi, document) = (d.f64("resolution", 72.0), d.bool("useDocumentResolution")); + let preset = RESOLUTIONS.iter().position(|(p, _)| (*p - ppi).abs() < 1e-9); + let mut labels: Vec<&str> = RESOLUTIONS.iter().map(|(_, l)| tl!(l)).collect(); + labels.push(tl!("Use Document Raster Effects Resolution")); + labels.push(tl!("Other")); + let shown = if document { RESOLUTIONS.len() } else { preset.unwrap_or(RESOLUTIONS.len() + 1) }; + widgets::label_row(ui, tl!("Resolution:"), LABEL, |ui| { + if let Some(i) = widgets::dropdown(ui, "rasterize-resolution", labels.get(shown).copied().unwrap_or_default(), &labels, 260.0) { + d.fields.insert("useDocumentResolution".into(), json!(i == RESOLUTIONS.len())); + if let Some((p, _)) = RESOLUTIONS.get(i) { + d.fields.insert("resolution".into(), json!(p)); + } else if i > RESOLUTIONS.len() && preset.is_some() { + // Other moves the value 1 ppi off the preset, so its own field shows. + d.fields.insert("resolution".into(), json!(ppi.round() + 1.0)); + } + } + if !document + && preset.is_none() + && let Some(v) = widgets::plain_field(ui, "rasterize-ppi", ppi, " ppi", 0, 80.0) + { + d.fields.insert("resolution".into(), json!(v)); + } + }); + ui.add_space(6.0); + widgets::label_row(ui, tl!("Background:"), LABEL, |ui| { + let cur = d.str("background"); + for (id, label) in [("white", tl!("White")), ("transparent", tl!("Transparent"))] { + if widgets::radio(ui, label, cur == id, true) { + d.fields.insert("background".into(), json!(id)); + } + ui.add_space(12.0); + } + }); + ui.add_space(10.0); + widgets::subheader(ui, tl!("Options")); + widgets::label_row(ui, tl!("Anti-aliasing:"), LABEL, |ui| { + use vectorcraft_render::AntiAlias; + let aa = AntiAlias::from_id(&d.str("antiAlias")).unwrap_or(AntiAlias::Art); + if let Some(i) = widgets::dropdown(ui, "rasterize-aa", aa.label(), &AntiAlias::ALL.map(AntiAlias::label), 170.0) { + d.fields.insert("antiAlias".into(), json!(AntiAlias::ALL.get(i).copied().unwrap_or(AntiAlias::Art).id())); + } + }); + check(ui, d, "clippingMask", tl!("Create Clipping Mask")); + let unit = app.session.general_unit(); + let t = crate::theme::Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Add")).color(t.text)); + if let Some(v) = widgets::num_field(ui, "rasterize-add-around", Some(d.f64("addAround", 0.0)), unit, 80.0) { + d.fields.insert("addAround".into(), json!(v)); + } + ui.label(egui::RichText::new(tl!("Around Object")).color(t.text)); + }); +} + #[cfg(test)] mod tests { use serde_json::json; @@ -149,4 +211,32 @@ mod tests { let st = app.session.active().unwrap(); assert!(st.selection.objects.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Image(_))))); } + + #[test] + fn rasterize_effect_dialog_previews_and_ok_applies_its_options_as_one_undo_step() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + let steps = app.session.active().unwrap().history.undo.len(); + crate::menus::invoke(&mut app, "effect.dialog", json!({"effect": "rasterize"})); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.fields["useDocumentResolution"].as_bool()), ("effect", Some(true))); + frame(&mut app); + assert!(app.session.in_interaction(), "previews"); + let d = app.ui.dialog.as_mut().unwrap(); + for (k, v) in + [("useDocumentResolution", json!(false)), ("resolution", json!(200)), ("background", json!("white")), ("antiAlias", json!("none"))] + { + d.fields.insert(k.into(), v); + } + // Other resolutions show their own field. + frame(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + let st = app.session.active().unwrap(); + let n = st.doc.node(vectorcraft_doc::NodeId(id)).unwrap(); + let (e, p) = (&n.appearance.effects[0], &n.appearance.effects[0].params); + assert_eq!((e.id.as_str(), &p["resolution"], &p["background"], &p["antiAlias"]), ("rasterize", &json!(200), &json!("white"), &json!("none"))); + assert!(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }), "the rectangle stays a path"); + assert_eq!(st.history.undo.len(), steps + 1); + } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 9acbb54a9..58356e1c2 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -770,6 +770,7 @@ Document open password Парола за отваряне на документ Document profile Профил на документ Document Raster Effects Settings Настройки на ефектите за растеризиране в документа + Use Document Raster Effects Resolution Използване на разделителната способност от настройките на ефектите за растеризиране в документа Document Raster Effects Settings Dialog Диалогов прозорец за настройки на ефектите за растеризиране в документа Document Raster Effects Settings… Настройки на ефектите за растеризиране в документа… Document Setup Настройка на документ diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index b289026ba..529161842 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3356,6 +3356,7 @@ theme Appearance Motiv Save Print Preset Uložit přednastavení tisku Print Tiling Pages Stránky dlaždic tisku Document Raster Effects Settings Nastavení rastrových efektů dokumentu + Use Document Raster Effects Resolution Použít rozlišení z nastavení rastrových efektů dokumentu Artwork Colors Barvy kresby Randomize Colors Náhodné barvy Recolor Artwork Přebarvit kresbu diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0dd943820..723dbf1af 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -771,6 +771,7 @@ Document open password Kennwort zum Öffnen des Dokuments Document profile Dokumentprofil Document Raster Effects Settings Dokument-Rastereffekt-Einstellungen + Use Document Raster Effects Resolution Auflösung der Dokument-Rastereffekt-Einstellungen verwenden Document Raster Effects Settings Dialog Dialogfeld „Dokument-Rastereffekt-Einstellungen“ Document Raster Effects Settings… Dokument-Rastereffekt-Einstellungen… Document Setup Dokument einrichten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 56703aea0..f1dc57e7b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -743,6 +743,7 @@ Document open password Κωδικός ανοίγματος εγγράφου Document profile Προφίλ εγγράφου Document Raster Effects Settings Ρυθμίσεις εφέ ράστερ εγγράφου + Use Document Raster Effects Resolution Χρήση ανάλυσης από τις ρυθμίσεις εφέ ράστερ εγγράφου Document Raster Effects Settings Dialog Παράθυρο ρυθμίσεων εφέ ράστερ εγγράφου Document Raster Effects Settings… Ρυθμίσεις εφέ ράστερ εγγράφου… Document Setup Ρύθμιση εγγράφου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4c6b07cb3..f46de8768 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -785,6 +785,7 @@ Document open password Contraseña de apertura del documento Document profile Perfil del documento Document Raster Effects Settings Ajustes de efectos de rasterización del documento + Use Document Raster Effects Resolution Usar la resolución de los ajustes de efectos de rasterización del documento Document Raster Effects Settings Dialog Cuadro de diálogo Ajustes de efectos de rasterización del documento Document Raster Effects Settings… Ajustes de efectos de rasterización del documento… Document Setup Ajustar documento diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2750d8a0..ee29e8af8 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3291,6 +3291,7 @@ theme Appearance Ulkoasu Save Print Preset Tallenna tulostuksen esiasetus Print Tiling Pages Tulosta sivujen ruudutus Document Raster Effects Settings Asiakirjan rasteritehosteiden asetukset + Use Document Raster Effects Resolution Käytä asiakirjan rasteritehosteiden asetusten tarkkuutta Artwork Colors Kuvituksen värit Randomize Colors Satunnaista värit Recolor Artwork Väritä kuvitus uudelleen diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 03146f17a..eb851dd1c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -787,6 +787,7 @@ Document open password Mot de passe d’ouverture du document Document profile Profil du document Document Raster Effects Settings Paramètres des effets de pixellisation du document + Use Document Raster Effects Resolution Utiliser la résolution des paramètres des effets de pixellisation du document Document Raster Effects Settings Dialog Boîte de dialogue Paramètres des effets de pixellisation du document Document Raster Effects Settings… Paramètres des effets de pixellisation du document… Document Setup Format de document diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 461070dcf..3906e2980 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1027,6 +1027,7 @@ Document open password Dokumentummegnyitási jelszó Document profile Dokumentumprofil Document Raster Effects Settings Dokumentum raszteres effektusainak beállítása + Use Document Raster Effects Resolution A dokumentum raszteres effektusainak felbontása Document Raster Effects Settings Dialog Dokumentum raszteres effektusainak beállítása – párbeszédablak Document Raster Effects Settings… Dokumentum raszteres effektusainak beállítása… Document Setup Dokumentumbeállítás diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 21cfcf18a..d2034e240 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -785,6 +785,7 @@ Document open password Password di apertura del documento Document profile Profilo documento Document Raster Effects Settings Impostazioni effetti raster documento + Use Document Raster Effects Resolution Usa la risoluzione delle impostazioni effetti raster documento Document Raster Effects Settings Dialog Finestra di dialogo Impostazioni effetti raster documento Document Raster Effects Settings… Impostazioni effetti raster documento… Document Setup Imposta documento diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 525c112f4..72498c353 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1245,6 +1245,7 @@ Document Ink Options ドキュメントのインキオプション Document JSON ドキュメント JSON Document Raster Effects Settings ドキュメントのラスタライズ効果設定 + Use Document Raster Effects Resolution ドキュメントのラスタライズ効果設定の解像度を使用 Document Raster Effects Settings Dialog ドキュメントのラスタライズ効果設定ダイアログ Document Setup ドキュメント設定 Document Title: ドキュメントのタイトル: diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 6443cfcc4..866cef4e7 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1240,6 +1240,7 @@ Document Ink Options 문서 잉크 옵션 Document JSON 문서 JSON Document Raster Effects Settings 문서 래스터 효과 설정 + Use Document Raster Effects Resolution 문서 래스터 효과 설정의 해상도 사용 Document Raster Effects Settings Dialog 문서 래스터 효과 설정 대화상자 Document Setup 문서 설정 Document Title: 문서 제목: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ae9765d1d..faaa67f72 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3211,6 +3211,7 @@ theme Appearance Wygląd Save Print Preset Zapisz ustawienie drukowania Print Tiling Pages Strony kafelkowania wydruku Document Raster Effects Settings Ustawienia efektów rastrowych dokumentu + Use Document Raster Effects Resolution Użyj rozdzielczości z ustawień efektów rastrowych dokumentu Artwork Colors Kolory kompozycji Randomize Colors Losuj kolory Recolor Artwork Zmień kolory kompozycji diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 63e725edf..17351c45b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -702,6 +702,7 @@ Document Ink Options Opções de Tinta do Documento Document JSON JSON do Documento Document Raster Effects Settings Configurações de Efeitos de Raster do Documento + Use Document Raster Effects Resolution Usar a Resolução das Configurações de Efeitos de Raster do Documento Document Raster Effects Settings Dialog Diálogo de Configurações de Efeitos de Raster do Documento Document Raster Effects Settings… Configurações de Efeitos de Raster do Documento… Document Setup Configuração do Documento diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ee7c6e5cd..a069afef9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -783,6 +783,7 @@ Document open password Пароль для открытия документа Document profile Профиль документа Document Raster Effects Settings Параметры растровых эффектов документа + Use Document Raster Effects Resolution Использовать разрешение из параметров растровых эффектов документа Document Raster Effects Settings Dialog Диалоговое окно «Параметры растровых эффектов документа» Document Raster Effects Settings… Параметры растровых эффектов документа… Document Setup Параметры документа diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb6ef9f23..1929f7216 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -772,6 +772,7 @@ Document open password Lösenord för att öppna dokumentet Document profile Dokumentprofil Document Raster Effects Settings Inställningar för rastereffekter i dokument + Use Document Raster Effects Resolution Använd upplösningen från inställningarna för rastereffekter i dokument Document Raster Effects Settings Dialog Dialogruta för inställningar för rastereffekter i dokument Document Raster Effects Settings… Inställningar för rastereffekter i dokument… Document Setup Dokumentinställningar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d80b7657e..1f9a25d3f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -779,6 +779,7 @@ Document open password Пароль для відкриття документа Document profile Профіль документа Document Raster Effects Settings Параметри растрових ефектів документа + Use Document Raster Effects Resolution Використовувати роздільну здатність із параметрів растрових ефектів документа Document Raster Effects Settings Dialog Діалогове вікно «Параметри растрових ефектів документа» Document Raster Effects Settings… Параметри растрових ефектів документа… Document Setup Параметри документа diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 68faa3b09..fb7446795 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -918,6 +918,7 @@ Document open password Mật khẩu mở tài liệu Document profile Hồ sơ tài liệu Document Raster Effects Settings Cài đặt hiệu ứng raster tài liệu + Use Document Raster Effects Resolution Dùng độ phân giải trong cài đặt hiệu ứng raster tài liệu Document Raster Effects Settings Dialog Hộp thoại cài đặt hiệu ứng raster tài liệu Document Raster Effects Settings… Cài đặt hiệu ứng raster tài liệu… Document Setup Thiết lập tài liệu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index bc23a1fcf..573edd271 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -736,6 +736,7 @@ Document open password 文档打开口令 Document profile 文档配置文档 Document Raster Effects Settings 文档栅格效果设置 + Use Document Raster Effects Resolution 使用文档栅格效果设置的分辨率 Document Raster Effects Settings Dialog 文档栅格效果设置对话框 Document Raster Effects Settings… 文档栅格化效果设置… Document Setup 文档设置 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f215ef7bd..af1ef53ee 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -754,6 +754,7 @@ Document open password 文件開啟密碼 Document profile 文件描述檔 Document Raster Effects Settings 文件點陣效果設定 + Use Document Raster Effects Resolution 使用文件點陣效果設定的解析度 Document Raster Effects Settings Dialog 文件點陣效果設定對話框 Document Raster Effects Settings… 文件點陣效果設定… Document Setup 文件設定 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6b322a891..435b36636 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3715,7 +3715,7 @@ fn effect_menu() -> Vec { Sep, Item::Header("Vector Effects"), ]; - // Submenus (and Crop Marks, an item of its own) in the reference app's order. + // Submenus (and Crop Marks and Rasterize…, items of their own) in the reference app's order. let order = [ "3D and Materials", "Color Adjustments", @@ -3724,6 +3724,7 @@ fn effect_menu() -> Vec { "Distort & Transform", "Path", "Pathfinder", + "Rasterize…", "Stylize", "SVG Filters", "Warp", @@ -3734,21 +3735,18 @@ fn effect_menu() -> Vec { // Glowing Edges). let in_vector_menu = |e: &vectorcraft_effects::EffectInfo| order.iter().any(|s| e.menu == ["Effect", *s]) && !vectorcraft_effects::PIXEL_EFFECTS.contains(&e.id); + let item = |e: &vectorcraft_effects::EffectInfo| match e.defaults.as_object().is_some_and(|o| o.is_empty()) { + // No options (Effect → Pathfinder, Crop Marks): apply directly, like Illustrator. + true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })), + false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })), + }; // The effects whose catalogue menu path is `path`. // `vector` picks the ones for the vector submenu or the ones for the raster submenu. - let items_at = |path: &[&str], vector: bool| -> Vec { - cat.iter() - .filter(|e| e.menu == path && in_vector_menu(e) == vector) - .map(|e| match e.defaults.as_object().is_some_and(|o| o.is_empty()) { - // No options (Effect → Pathfinder): apply directly, like Illustrator. - true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })), - false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })), - }) - .collect() - }; + let items_at = + |path: &[&str], vector: bool| -> Vec { cat.iter().filter(|e| e.menu == path && in_vector_menu(e) == vector).map(item).collect() }; for sub_name in order { if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) { - out.push(Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id }))); + out.push(item(e)); continue; } let items = items_at(&["Effect", sub_name], true); diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index 73fb99b42..4c421b3aa 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -68,6 +68,18 @@ fn effect_menu_lists_outline_object_between_offset_path_and_outline_stroke() { ); } +/// Effect › Rasterize…, an item of its own between Pathfinder and Stylize, opens its options. +#[test] +fn effect_menu_lists_rasterize_between_pathfinder_and_stylize() { + let app = app(); + let entries = menus::menu_entries(&app); + let at = |path: &[&str], label: Option<&str>| entries.iter().position(|e| e.path == path && label.is_none_or(|l| e.label == l)).unwrap(); + let rasterize = at(&["Effect"], Some("Rasterize…")); + assert!(at(&["Effect", "Pathfinder"], None) < rasterize && rasterize < at(&["Effect", "Stylize"], None)); + let e = &entries[rasterize]; + assert_eq!((e.command.as_deref(), e.params["effect"].as_str()), (Some("effect.dialog"), Some("rasterize"))); +} + #[test] fn effect_menu_lists_film_grain_in_artistic_before_blur() { let app = app(); diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 09ac1f415..e9497ab58 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -11,7 +11,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( | Group | Illustrator | VectorCraft | % | Remaining (h) | |---|---:|---:|---:|---:| -| Illustrator effects (vector and stylize) | 50 | 45 | 90% | 6–10 | +| Illustrator effects (vector and stylize) | 50 | 46 | 92% | 6–10 | | Photoshop effects (raster, Effect Gallery) | 57 | 30 | 53% | 22–38 | | 3D and Materials | 4 (+3 Classic) | 4 (initial: Extrude & Bevel, Revolve, Inflate, Rotate) | ~25% | 25–45 | | Beyond Illustrator: Effect › Color Adjustments (6), effect plug-ins (WebAssembly) | — | 6 + plug-ins | — | — | @@ -25,7 +25,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( | Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Transform's Transform Objects/Patterns and Scale Strokes & Effects options, invisible Reflect checkboxes (#885); Zig Zag's ridges are evenly spaced along each segment (#1558); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646) | | Path | Offset Path, Outline Object, Outline Stroke | 3/3 | With Use Preview Bounds on, Align, Distribute and the Transform panel still measure type that carries Outline Object by its layout box (preview bounds leave out effects) | | Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 10/13 | Hard Mix, Soft Mix, Trap | -| Rasterize | Rasterize… (as a live effect) | 0/1 | the live effect (Object › Rasterize exists) | +| Rasterize | Rasterize… (as a live effect) | 1/1 | — | | Stylize | Drop Shadow, Feather, Inner Glow, Outer Glow, Round Corners, Scribble | 6/6 | Scribble Options' Settings presets | | SVG Filters | Apply SVG Filter… and the filter list | 0/1 | the whole submenu (SVG filters on import round-trip already) | | Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; Arc is a circular arc in the object's own proportions (#1578) | diff --git a/docs/mcp.md b/docs/mcp.md index b6c980a1c..2754b4b20 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2350,6 +2350,24 @@ fills or strokes carry effects is written as one piece per fill and stroke, so o {"name":"run_command","arguments":{"command":"document.rasterEffectsSettings","params":{"resolution":"high","background":"white","addAround":36}}} ``` +`effect.apply {effect: "rasterize"}` (Effect › Rasterize…) draws an object as an image of square pixels while its +paths, type and other art stay editable. It takes the options of Object › Rasterize under these names: `colorModel` +(`document`, `grayscale` or `bitmap`), `useDocumentResolution` (true by default: the resolution above) or +`resolution` (1 to 2400 ppi), `background` (`transparent` by default, or `white`), `antiAlias` (`art`, `type` or +`none`), `clippingMask` and `addAround` (points). The image covers the object's bounds and the room around them, +its first pixel at their top-left corner. Both `object.rasterize` and the effect measure the art as drawn: with its +geometry effects, its brush art and its raster effects. The effects above Rasterize in the stack, shadows included, +are part of the image, and the shadows and glows below it are cast by the image. The canvas draws an image of up to +8.4 million pixels, at most 32,768 a side, on its own grid and then at the view's zoom: an image pixel wider than a +screen pixel shows as one flat square, and smaller ones are averaged. A larger image is made from the art drawn at the canvas's resolution +(lower for very large art), and its pixels no wider than a pixel of that drawing show the art as drawn. +When Rasterize is an object's only raster effect, Expand Appearance and PDF and SVG export replace the object with the +image `object.rasterize` makes with the same options. + +```json +{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"rasterize","params":{"useDocumentResolution":false,"resolution":150,"background":"white"}}}} +``` + ## Glass textures Glass (`distort.glass`) takes an image file as its surface in `params.textureImage`, as `{path}` or diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7f666994a..aa4141a3e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -74,7 +74,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient dither, perceptual blending and gradient presets; freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251) | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; remaining: Art brush Overlap, Pattern brush auto corners, brush libraries, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated | -| Live vector effects | 5 | 85% | Outline Object, Effect › Rasterize, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 44/50, depth estimated; Warp Options controls verified by focused checks and original-image review | +| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 46/50, depth estimated; Warp Options controls verified by focused checks and original-image review | | Raster effects (Effect Gallery) | 4 | 49% | 27 Photoshop-style filters (14 Artistic, 13 Sketch), the Effect Gallery dialog, Load Texture | 22–38 | measured count 30/57; texture redraw distances optimized (#1257), pixels verified; depth estimated | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (other types use flat shading; one light, painter visibility); materials, several lights, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; remaining: hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass | @@ -650,6 +650,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Malformed MCP replies:** invalid JSON from the app drops the connection, reports that the operation may have completed and leaves the next call to reconnect; it never replays the edit (#1761). TCP regressions cover malformed and uncorrelated replies, connection closure and a subsequent inspection. Envelope regressions also verify zero backend calls and unchanged initialization state for invalid versions. - **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`). Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780). One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779). A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775). Tests: text import fuzzing no longer searches installed fonts (#1785). Tests: render tests that add fonts no longer race the frame cache tests (#1795). - **Raster-effect resolution in PDF:** when the 64 million pixel cap on an effect image renders an object's raster effects below the document's raster effects resolution, PDF export warns once per object with the requested and the lowest achieved ppi (`crates/engine/src/tests_pdfraster.rs`). +- **Rasterize effect:** Effect › Rasterize… (`rasterize`) draws an object as an image of square pixels at its own resolution or the document's raster effects resolution, with Object › Rasterize's color model, background, anti-aliasing, clipping mask and room around the object, while its paths stay editable. Expand Appearance and PDF and SVG export of an object whose only raster effect is Rasterize give the image Object › Rasterize gives (`crates/engine/src/tests_rastersettings.rs`, `crates/render/src/tests_rasterize.rs`). Behavior reference: [How to save artwork in Illustrator](https://helpx.adobe.com/illustrator/using/saving-artwork.html), [Work with effects](https://helpx.adobe.com/illustrator/using/effects.html). - **Pattern tiles:** the renderer keeps a tile of each pattern for each scale it rasterizes the pattern at, until the tiles fill the tile cache's budget, and then drops the least recently used tiles first. Objects that show one pattern at different scales, such as a pattern scaled to 25% with Transform Pattern Tiles next to the same pattern at 100%, draw from tiles kept between frames. From 1d7ad173aad7850fd4f025a447adc709f28b42d9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 00:04:27 -0700 Subject: [PATCH 002/208] Fix #866: macOS save sheets have a Format menu again On macOS, the save sheets of Save As, Save a Copy and the first Save of a new document have a Format menu again, and the Save as Template sheet now has one too. The panel is an NSSavePanel built in apps/vectorcraft/src/mac_save_format.rs, and the menu is its accessory view, set before beginSheetModalForWindow:completionHandler: shows the sheet. The menu from #1235 was added after rfd had begun the sheet, and recent macOS versions dismissed the sheet at once (#1800, #1831, #1518); #1855 removed it. Choosing a format changes the extension in the name field and puts that format's extension first in the allowed file types, which AppKit adds to a name typed without one. A name typed with another listed extension is saved in that format. Save panels with one file type and the open and folder panels are still shown by rfd. Docs: docs/target-app-parity.md, docs/ui-parity.md. --- apps/vectorcraft/src/mac_save_format.rs | 253 ++++++++++++++++++++++++ apps/vectorcraft/src/main.rs | 22 ++- docs/target-app-parity.md | 1 + docs/ui-parity.md | 2 +- 4 files changed, 270 insertions(+), 8 deletions(-) create mode 100644 apps/vectorcraft/src/mac_save_format.rs diff --git a/apps/vectorcraft/src/mac_save_format.rs b/apps/vectorcraft/src/mac_save_format.rs new file mode 100644 index 000000000..519c98097 --- /dev/null +++ b/apps/vectorcraft/src/mac_save_format.rs @@ -0,0 +1,253 @@ +//! macOS: the Format menu on save sheets (#866). A save panel with two or more file types (Save As, +//! Save a Copy, Save as Template and the first Save of a new document) is an `NSSavePanel` made here +//! instead of by rfd, whose Cocoa backend lists the extensions of every filter but not their names. +//! The panel gets a Format menu as its accessory view +//! () before +//! `beginSheetModalForWindow:completionHandler:` +//! () +//! shows it: an accessory set after the sheet began made recent macOS versions dismiss the sheet at +//! once (#1800, #1831, #1518). Choosing a format changes the extension in the name field and puts +//! the format's extension first in the allowed file types, which AppKit adds to a name typed without +//! one. The save command takes the format from the extension, so an extension typed by hand still +//! decides it. +//! +//! The panel retains the accessory, and the completion handler holds the panel until the sheet +//! closes; the popup's target is the accessory itself. The accessory holds the panel weakly, so +//! closing the sheet releases both. Reading the window from its handle and subclassing `NSView` for +//! the menu's action have no safe API; this module allows `unsafe_code`. +#![allow(unsafe_code)] + +use std::sync::mpsc::{Receiver, channel}; + +use block2::RcBlock; +use objc2::rc::{Retained, Weak}; +use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel}; +use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePanel, NSTextField, NSView}; +use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL}; +use raw_window_handle::{HasWindowHandle, RawWindowHandle}; +use vectorcraft_engine::cmd::fileio; +use vectorcraft_ui_egui::{FilePick, i18n}; + +type Filter = (&'static str, &'static [&'static str]); + +/// The accessory's height and the room around and between its label and menu, in points. +const HEIGHT: f64 = 38.0; +const MARGIN: f64 = 12.0; +const GAP: f64 = 8.0; +/// The Format menu's size, in points. +const MENU: NSSize = NSSize::new(260.0, 26.0); + +struct Ivars { + panel: Weak, + filters: Vec, +} + +define_class!( + // SAFETY: NSView has no extra subclassing requirements; the subclass implements no Drop. + #[unsafe(super = NSView)] + #[thread_kind = MainThreadOnly] + #[ivars = Ivars] + #[name = "VectorcraftSaveFormatAccessory"] + struct FormatAccessory; + + // SAFETY: NSObjectProtocol has no additional requirements. + unsafe impl NSObjectProtocol for FormatAccessory {} + + impl FormatAccessory { + // SAFETY: AppKit's target/action sends the popup as the single object argument. + #[unsafe(method(chooseFormat:))] + fn choose_format(&self, sender: &NSPopUpButton) { + let Some(panel) = self.ivars().panel.load() else { return }; + let Some(index) = usize::try_from(sender.indexOfSelectedItem()).ok() else { return }; + let Some(extension) = self.ivars().filters.get(index).and_then(|(_, extensions)| extensions.first()) else { return }; + set_allowed_types(&panel, &self.ivars().filters, index); + let name = panel.nameFieldStringValue().to_string(); + panel.setNameFieldStringValue(&NSString::from_str(&filename(&name, extension, &self.ivars().filters))); + } + } +); + +/// The file types a Format menu lists: `filters` without "every file" ones. Empty when fewer than +/// two remain, and the panel is then left to rfd. +fn menu_formats(filters: &[Filter]) -> Vec { + let named: Vec = filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect(); + if named.len() < 2 { Vec::new() } else { named } +} + +/// The menu entry of the suggested name's type, or the first (the request's default). +fn initial_format(name: &str, filters: &[Filter]) -> usize { + let extension = fileio::extension(name); + filters.iter().position(|(_, extensions)| extensions.iter().any(|x| extension.eq_ignore_ascii_case(x))).unwrap_or(0) +} + +/// `name` with `extension`: a format's extension (one in the menu, or any other the app reads or +/// writes) is replaced, and other dots stay part of the name (a version number). +fn filename(name: &str, extension: &str, filters: &[Filter]) -> String { + if name.is_empty() { + return String::new(); + } + let has = fileio::extension(name); + let known = + filters.iter().any(|(_, extensions)| extensions.iter().any(|x| has.eq_ignore_ascii_case(x))) || fileio::format_for_name(name).is_some(); + if known { std::path::Path::new(name).with_extension(extension).to_string_lossy().into_owned() } else { format!("{name}.{extension}") } +} + +/// The extensions a panel with menu `filters` allows, those of entry `selected` first. AppKit adds +/// the first one to a name typed without an extension, and a name typed with another of them is +/// saved in that format. +fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&'static str> { + let first = filters.get(selected).map_or(&[][..], |(_, extensions)| *extensions); + let others = filters.iter().enumerate().filter(|(i, _)| *i != selected).flat_map(|(_, (_, extensions))| extensions.iter()); + first.iter().chain(others).copied().collect() +} + +/// Set `panel`'s allowed file types ([`allowed_types`]). +fn set_allowed_types(panel: &NSSavePanel, filters: &[Filter], selected: usize) { + let types: Vec> = allowed_types(filters, selected).into_iter().map(NSString::from_str).collect(); + // rfd sets the same deprecated property (`add_filters` in its `panel_ffi.rs`). + #[allow(deprecated)] + panel.setAllowedFileTypes(Some(&NSArray::from_retained_slice(&types))); +} + +/// Show the save panel for `pick` as a sheet on `window`, with a Format menu, and answer on the +/// receiver with the path picked (none when canceled). `None` when the request has fewer than two +/// named file types, or `window` has no AppKit window; nothing is shown then. +pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option>> { + let mtm = MainThreadMarker::new()?; + let filters = menu_formats(&pick.filters); + if filters.is_empty() { + return None; + } + let RawWindowHandle::AppKit(handle) = window.window_handle().ok()?.as_raw() else { return None }; + // SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the + // window is open, and winit hands out the handle only while it is. + let view = unsafe { handle.ns_view.cast::().as_ref() }; + let parent = view.window()?; + let panel = NSSavePanel::savePanel(mtm); + let selected = initial_format(&pick.name, &filters); + set_allowed_types(&panel, &filters, selected); + if let Some(folder) = &pick.folder { + panel.setDirectoryURL(Some(&NSURL::fileURLWithPath_isDirectory(&NSString::from_str(folder), true))); + } + panel.setExtensionHidden(false); + if !pick.name.is_empty() { + panel.setNameFieldStringValue(&NSString::from_str(&pick.name)); + } + let accessory = accessory(mtm, &panel, filters, selected); + panel.setAccessoryView(Some(&accessory)); + let (tx, rx) = channel(); + let answered = panel.clone(); + let handler = RcBlock::new(move |response: NSModalResponse| { + let picked = answered.URL().and_then(|url| url.path()).map(|p| p.to_string()); + let paths = if response == NSModalResponseOK { picked.into_iter().collect() } else { Vec::new() }; + // The app gone meanwhile has no use for the answer. + let _ = tx.send(paths); + }); + // The panel and its accessory are set up; AppKit keeps the handler (and the panel it holds) + // until the sheet closes. + panel.beginSheetModalForWindow_completionHandler(&parent, &handler); + Some(rx) +} + +/// The "Format:" label and menu of `filters`, `selected` chosen, that rename `panel`'s file. +fn accessory(mtm: MainThreadMarker, panel: &NSSavePanel, filters: Vec, selected: usize) -> Retained { + let label = NSTextField::labelWithString(&NSString::from_str(i18n::t("Format:")), mtm); + label.sizeToFit(); + let size = label.frame().size; + label.setFrameOrigin(NSPoint::new(MARGIN, (HEIGHT - size.height) / 2.0)); + let x = MARGIN + size.width + GAP; + let popup = + NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::new(NSPoint::new(x, (HEIGHT - MENU.height) / 2.0), MENU), false); + for (name, _) in &filters { + popup.addItemWithTitle(&NSString::from_str(i18n::tr_ctx(i18n::current(), "file-filter", name))); + } + // The filter list comes from the built-in format registry, small enough for NSInteger. + if let Ok(index) = isize::try_from(selected) { + popup.selectItemAtIndex(index); + } + let this = FormatAccessory::alloc(mtm).set_ivars(Ivars { panel: Weak::new(panel), filters }); + let frame = NSRect::new(NSPoint::ZERO, NSSize::new(x + MENU.width + MARGIN, HEIGHT)); + // SAFETY: NSView's designated initializer is called once on the allocated subclass. + let accessory: Retained = unsafe { msg_send![super(this), initWithFrame: frame] }; + // SAFETY: the accessory holds the popup as a subview and AppKit keeps the accessory while the + // sheet is open. The selector has the target/action signature NSControl calls it with. + unsafe { + popup.setTarget(Some(&accessory)); + popup.setAction(Some(sel!(chooseFormat:))); + } + accessory.addSubview(&label); + accessory.addSubview(&popup); + accessory +} + +#[cfg(test)] +mod tests { + use super::*; + + const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])]; + + /// Save, Save As and Save a Copy list every save format with the default first; a panel with one + /// named type, or one for every file, gets no menu, and rfd shows it. + #[test] + fn save_panels_with_several_formats_get_the_menu() { + let save = menu_formats(&fileio::save_filters("vectorcraft")); + assert!(save.len() >= 2, "{save:?}"); + assert_eq!(save.first().map(|(_, e)| *e), Some(&["vectorcraft"][..])); + assert!(menu_formats(&fileio::save_filters("svg")).first().is_some_and(|(_, e)| e.contains(&"svg"))); + assert!(menu_formats(&fileio::template_filters("template")).len() >= 2); + assert!(menu_formats(&[("PDF", &["pdf"])]).is_empty()); + assert!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("Any", &[])]).is_empty()); + assert_eq!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("SVG", &["svg"])]).len(), 2); + } + + #[test] + fn the_suggested_name_selects_its_format_and_an_unknown_one_the_default() { + for (name, index) in [("Untitled-1", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0), ("", 0)] { + assert_eq!(initial_format(name, FILTERS), index, "{name}"); + } + } + + #[test] + fn choosing_a_format_changes_the_extension_and_keeps_the_name() { + for (name, expected) in [ + ("図面 1.vectorcraft", "図面 1.svg"), + ("a.PDF", "a.svg"), + ("art", "art.svg"), + ("art.v2", "art.v2.svg"), + ("art.svg", "art.svg"), + ("old.drawcraft", "old.svg"), + ("photo.png", "photo.svg"), + ("", ""), + ] { + assert_eq!(filename(name, "svg", FILTERS), expected, "{name}"); + } + } + + /// A name typed without an extension gets the chosen format's: AppKit adds the first allowed + /// type. The others stay allowed. + #[test] + fn the_chosen_format_is_the_first_allowed_type() { + assert_eq!(allowed_types(FILTERS, 0), ["vectorcraft", "svg", "pdf"]); + assert_eq!(allowed_types(FILTERS, 1), ["svg", "vectorcraft", "pdf"]); + assert_eq!(allowed_types(FILTERS, 2), ["pdf", "vectorcraft", "svg"]); + assert_eq!(allowed_types(FILTERS, 9), ["vectorcraft", "svg", "pdf"]); + } + + /// Each entry of the Save As menu gives a name the save command reads as that entry's format. + #[test] + fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() { + let filters = menu_formats(&fileio::save_filters("vectorcraft")); + for (label, extensions) in &filters { + let name = filename("Untitled-1.vectorcraft", extensions[0], &filters); + assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(*label), "{name}"); + } + } + + /// The menu's label is translated in every language with complete menus. + #[test] + fn the_label_is_translated() { + for lang in i18n::Lang::all().filter(|l| l.complete_menus()) { + assert!(i18n::has(lang, "Format:"), "{}", lang.code()); + } + } +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index bfa0f3a49..17bc010a3 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -35,6 +35,8 @@ mod mac_fonts; #[cfg(target_os = "macos")] mod mac_menu; #[cfg(target_os = "macos")] +mod mac_save_format; +#[cfg(target_os = "macos")] mod mac_screen_color; #[cfg(target_os = "macos")] mod mac_window; @@ -473,7 +475,7 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti }) .ok()?; make_folder(&request); - // rfd builds the panel and begins the sheet here, on the main thread (as it must). + // The panel is built and the sheet is begun below, on the main thread (as it must). let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request); let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string(); let answer: SheetAnswer = match request { @@ -485,12 +487,18 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti let picked = dialog.pick_files(); Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() }) } - PickRequest::Save(_) => { - // Nothing is added to the sheet once rfd has begun it: an accessory view set then made - // recent macOS versions dismiss the sheet at once, so nothing could be saved (#1800). - let picked = dialog.save_file(); - Box::pin(async move { picked.await.iter().map(path).collect() }) - } + // A save panel with two or more named file types is built by `mac_save_format`, which sets + // up its Format menu before the sheet begins (#866); rfd shows the others. + PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) { + // The dialog thread blocks here until the sheet answers. + Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }), + None => { + // Nothing is added to the sheet once rfd has begun it: an accessory view set then + // made recent macOS versions dismiss the sheet at once (#1800). + let picked = dialog.save_file(); + Box::pin(async move { picked.await.iter().map(path).collect() }) + } + }, PickRequest::Folder => { let picked = dialog.pick_folder(); Box::pin(async move { picked.await.iter().map(path).collect() }) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7f666994a..0effc81cf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -596,6 +596,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - PDF export and print composition use the optional shared guarded writer ([pdf-backend.md](pdf-backend.md)); app drawing and output policy and desktop printer submission remain in their existing layers. The shared reader keeps the app-owned import adapter; synthetic tests cover layer and overprint state across windows, long operands, inline images, failed decoding, document limits and incomplete tails. Empty and populated metadata key presence is regression-tested, and CI checks the immutable backend source. A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests_import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them. - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604). + - macOS: the save sheet of Save As, Save a Copy, Save as Template and the first Save of a new document has a Format menu that lists each format the save can write, with the suggested file's format selected. Choosing a format changes the extension in the name field, and a name typed without an extension gets the chosen format's. A name typed with another listed extension is saved in that format. The menu is set up before the sheet opens; a save panel with one file type is shown by rfd as before (#866, `apps/vectorcraft/src/mac_save_format.rs`). - Save as Template writes a PDF-compatible `.ait` as well as a `.vctemplate`, which stays the default: the save panel and the web build's Save as Template dialog list both, and New from Template opens the `.ait` as an untitled document with its layers and art (`file.saveAsTemplate {format: "ait"}`, or a path ending in `.ait`). - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs and the images in an `.ai` or EPS file's editing data, an alpha channel coming in as a grayscale opacity mask) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5544889cb..de4d0a3cd 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -213,7 +213,7 @@ panel-menu commands such as Break Link to Symbol), so they can be given shortcut | SVG Interactivity, Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) | | Workspaces (save, reset, switch) | done | | | DXF export/import preserves intended curve closure | done | #1299; R13–2018 closed SPLINE bit set/read; an open curve ending at its start stays open; knots and control points unchanged; native CIRCLE/ARC export and CNC preset remain pending | -| macOS save sheet: choose the format from a menu | missing | #866 (reopened); the menu was withdrawn by #1855 to restore saving: Save As, Save a Copy and the first Save showed no sheet with it. It comes back once the format can be set before the sheet opens | +| macOS save sheet: choose the format from a menu | done | #866; the save panel is VectorCraft's own `NSSavePanel` with its Format menu set before the sheet opens (`apps/vectorcraft/src/mac_save_format.rs`); #1855 had withdrawn the menu that was added after the sheet began, which made Save As, Save a Copy and the first Save show no sheet | | Preferences: every option acts | partial | ~16 stored but unused (#394) | | Tabs panel: a stop drags from where the press landed, a stop just added included | done | #1458 | | Outline view keeps template layers in Preview, dimmed | done | #1486; a picture placed to be traced stays visible under the outlines | From 816097c6bf65e55573ce4120d4400e1e4bed5a5e Mon Sep 17 00:00:00 2001 From: sanches Date: Fri, 16 Oct 2026 12:05:31 +0300 Subject: [PATCH 003/208] i18n: add Dutch, Danish and Indonesian interfaces Complete catalogs for nl, da and id, registered with their locales. Hindi from the same PR waits for a Devanagari UI font decision (with Lao and Georgian), and the strings main gained since the catalogs were written are added. --- crates/ui-egui/src/i18n/da.tsv | 5035 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/id.tsv | 5035 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/mod.rs | 22 +- crates/ui-egui/src/i18n/nl.tsv | 5035 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/tests.rs | 4 +- 5 files changed, 15128 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/i18n/da.tsv create mode 100644 crates/ui-egui/src/i18n/id.tsv create mode 100644 crates/ui-egui/src/i18n/nl.tsv diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv new file mode 100644 index 000000000..3f67eec54 --- /dev/null +++ b/crates/ui-egui/src/i18n/da.tsv @@ -0,0 +1,5035 @@ +# Danish (da) catalog. Same format as zh-hant.tsv: context source translation +# (empty context = a plain UI string; `@id` = a command id; `@plural` = "one|other" forms) +# +# Clean-room: written from the meaning of VectorCraft's English labels in neutral, international +# Danish, using the feature names Danish speakers of vector illustration software know. No other +# localisation was copied. Labels use sentence case (only the first word capitalised). + New Design Nyt design + Delete Design Slet design + Paste Design Indsæt design + Select Unused Markér ubrugte + Glowing Edges… Glødende kanter… + About VectorCraft Om VectorCraft + Align to Path Justér til kurve + Cut Path at Selected Anchor Points Klip kurve ved valgte ankerpunkter + Join Our Discord Slut dig til vores Discord + Language Sprog + UI Brightness Lysstyrke i brugerfladen + Dark Mørk + Medium Dark Mellemmørk + Medium Light Mellemlys + Light Lys + Quit VectorCraft Slut VectorCraft + File Filer + New… Ny… + New from Template… Ny fra skabelon… + Open… Åbn… + Open Recent Files Åbn seneste filer + Clear Recent Files Ryd seneste filer + Show in Folder Vis i mappe + Close Luk +@id file.close file.close + Close All Luk alle + Save Gem + Save As… Gem som… + Save a Copy… Gem en kopi… + Save as Template… Gem som skabelon… + Save as PDF… Gem som PDF… + Save for Office Documents… Gem til Office-dokumenter… + Revert Gendan + Place… Placér… + Export Eksportér + Export for Screens… Eksportér til skærme… + Export As… Eksportér som… + Export As SVG… Eksportér som SVG… + Export As PNG… Eksportér som PNG… + Save for Web (Legacy)… Gem til web (ældre)… + Export Selection… Eksportér markering… + Package… Pakke… + Scripts Scripter + Check Script… Kontrollér script… + Check Script Kontrollér script + Other Script… Andet script… + Document Setup… Dokumentopsætning… + Document Color Mode Dokumentets farvetilstand + CMYK Color CMYK-farve + RGB Color RGB-farve + File Info… Filoplysninger… + Print… Udskriv… + Edit Redigér + Undo Fortryd + Redo Gør om + Cut Klip + Copy Kopiér + Paste Indsæt + Paste in Front Indsæt foran + Paste in Back Indsæt bagved + Paste in Place Indsæt på samme sted + Paste on All Artboards Indsæt på alle tegneflader + Paste without Formatting Indsæt uden formatering + Clear Fjern + Find and Replace… Find og erstat… + Find Next Find næste + Spelling Stavekontrol + Auto Spell Check Automatisk stavekontrol + Check Spelling… Kontrollér stavning… + Edit Custom Dictionary… Redigér egen ordliste… + Edit Colors Redigér farver + Recolor Artwork… Omfarv grafik… + Recolor with Preset Omfarv med forudindstilling + 1 Color Job… 1-farvejob… + 2 Color Job… 2-farvejob… + 3 Color Job… 3-farvejob… + Color Library… Farvebibliotek… + Adjust Color Balance… Justér farvebalance… + Blend Front to Back Bland fortil til bagtil + Blend Horizontally Bland vandret + Blend Vertically Bland lodret + Convert to CMYK Konvertér til CMYK + Convert to Grayscale Konvertér til gråtoner + Convert to RGB Konvertér til RGB + Invert Colors Invertér farver + Overprint Black… Overprint sort… + Saturate… Mæt… + Edit Original Redigér original + Transparency Flattener Presets… Forudindstillinger for udjævning af gennemsigtighed… + Print Presets… Forudindstillinger for udskrivning… + PDF Presets… PDF-forudindstillinger… + Perspective Grid Presets… Forudindstillinger for perspektivgitter… + Color Settings… Farveindstillinger… + Assign Profile… Tildel profil… + Keyboard Shortcuts… Tastaturgenveje… + Import Shortcut Set Importér genvejssæt + Import Shortcut Set… Importér genvejssæt… + Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. Hver post i filen matches med et VectorCraft-værktøj, en kommando eller et panel. Kontrollér matchene, og angiv det, der mangler. + Keys Taster + The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. Filen skriver taster og ændringstaster som koder. Kontrollér de foreslåede betydninger, og angiv de koder, der endnu ikke er kendt. + Click, then press the key this code stands for (Esc cancels) Klik, og tryk derefter på den tast, som koden står for (Esc annullerer) + Press a key… Tryk på en tast… + from your earlier imports fra dine tidligere importer + from the format's public description fra formatets offentlige beskrivelse + not known yet endnu ikke kendt + Shortcuts Genveje + In the File I filen + In VectorCraft I VectorCraft + Needs Attention Only Kræver kun opmærksomhed + Ready Klar + Needs a choice Kræver et valg + Needs a key Kræver en tast + No equivalent Ingen tilsvarende + Tools take a single key (optionally with Shift) Værktøjer bruger en enkelt tast (eventuelt med Shift) + A later row takes this shortcut En senere række bruger denne genvej + Same item as a later row Samme element som en senere række + Start from VectorCraft Defaults Start fra VectorCrafts standarder + Add to the Current Shortcuts Føj til de aktuelle genveje + Remember My Choices Husk mine valg + {ready} of {total} ready {ready} af {total} klar + The file is too large to read in full, so only its first part is listed. Filen er for stor til at blive læst i sin helhed, så kun dens første del vises. + Report copied Rapporten er kopieret + Line {line} Linje {line} + Line {line}: {name}: {status} Linje {line}: {name}: {status} + Read Shortcut Set File Læs fil med genvejssæt + Import Matches Importér match + Remember Import Matches Husk importmatch + Forget Import Matches Glem importmatch +@plural agrees with {n} default shortcut|agrees with {n} default shortcuts stemmer overens med {n} standardgenvej|stemmer overens med {n} standardgenveje +@plural {n} part of the file wasn't read|{n} parts of the file weren't read {n} del af filen blev ikke læst|{n} dele af filen blev ikke læst +@msg This shortcut file is in a form VectorCraft can't read. Denne genvejsfil er i et format, som VectorCraft ikke kan læse. +@msg not a shortcut set: the text doesn't start with a slash name and an opening brace ikke et genvejssæt: teksten begynder ikke med et skråstregnavn og en startklammeparentes + Preferences… Indstillinger… + Object Objekt + Transform Transformér + Transform Again Transformér igen + Move… Flyt… + Rotate… Rotér… + Reflect… Spejl… + Scale… Skalér… + Shear… Skæv… + Transform Each… Transformér hver for sig… + Reset Bounding Box Nulstil afgrænsningsramme + Arrange Arrangér + Bring to Front Anbring forrest + Bring Forward Flyt fremad + Send Backward Flyt bagud + Send to Back Anbring bagerst + Send to Current Layer Send til aktuelt lag + Align Justér + Horizontal Align Left Justér vandret mod venstre + Horizontal Align Center Justér vandret mod midten + Horizontal Align Right Justér vandret mod højre + Vertical Align Top Justér lodret mod toppen + Vertical Align Center Justér lodret mod midten + Horizontal & Vertical Align Center Justér vandret og lodret mod midten + Vertical Align Bottom Justér lodret mod bunden + Group Gruppér + Ungroup Afgruppér + Lock Lås + Selection Markering + All Artwork Above Al grafik ovenfor + Other Layers Andre lag + Unlock All Lås alle op + Hide Skjul + Show All Vis alle + Expand… Udvid… + Expand Appearance Udvid udseende + Crop Image Beskær billede + Mask Maskér + Rasterize… Rasterisér… + Create Gradient Mesh… Opret gradientnet… + Create Object Mosaic… Opret objektmosaik… + Vector Halftone… Vektorhalvtone… + Create Trim Marks Opret beskæringsmærker + Flatten Transparency… Udjævn gennemsigtighed… + Make Pixel Perfect Gør pixelperfekt + Slice Udsnit + Make Opret + Make and Expand Opret og udvid + Release Frigør + Create from Guides Opret ud fra hjælpelinjer + Create from Selection Opret ud fra markering + Duplicate Slice Duplikér udsnit + Combine Slices Kombinér udsnit + Divide Slices… Opdel udsnit… + Delete All Slet alle + Slice Options… Udsnitsindstillinger… + Clip to Artboard Klip til tegneflade + Path Kurve + Join Forbind + Average… Gennemsnit… + Outline Stroke Streg til kontur + Offset Path… Forskyd kurve… + Reverse Path Direction Omvend kurveretning + Simplify… Forenkl… + Add Anchor Points Tilføj ankerpunkter + Remove Anchor Points Fjern ankerpunkter + Divide Objects Below Opdel objekter nedenunder + Split Into Grid… Opdel i gitter… + Clean Up… Ryd op… + Shape Form + Convert to Shape Konvertér til form + Expand Shape Udvid form + Pattern Mønster + Edit Pattern Redigér mønster + Tile Edge Color… Kantfarve på fliser… + Repeat Gentag + Radial Radial + Grid Gitter + Mirror Spejl + Options… Indstillinger… + Blend Blanding + Blend Options… Blandingsindstillinger… + Expand Udvid + Replace Spine Erstat rygrad + Reverse Spine Omvend rygrad + Reverse Front to Back Omvend fortil og bagtil + Envelope Distort Kuvertforvrængning + Make with Warp… Opret med vridning… + Reset with Warp… Nulstil med vridning… + Make with Mesh… Opret med net… + Reset with Mesh… Nulstil med net… + Make with Top Object Opret med øverste objekt + Envelope Options… Kuvertindstillinger… + Edit Contents Redigér indhold + Edit Envelope Redigér kuvert + Perspective Perspektiv + Attach to Active Plane Fastgør til aktivt plan + Release with Perspective Frigør med perspektiv + Move Plane to Match Object Flyt plan, så det matcher objektet + Edit Text Redigér tekst + Live Paint Live-maling + Merge Flet + Gap Options… Mellemrumsindstillinger… + Image Trace Billedsporing + Image Trace (Make and Expand) Billedsporing (opret og udvid) + Release Image Trace Frigør billedsporing + Text Wrap Tekstombrydning + Make Text Wrap Opret tekstombrydning + Release Text Wrap Frigør tekstombrydning + Text Wrap Options… Indstillinger for tekstombrydning… + Clipping Mask Beskæringsmask + Edit Clipping Path Redigér beskæringskurve + Compound Path Sammensat kurve + Artboards Tegneflader + Convert to Artboards Konvertér til tegneflader + Rearrange All Artboards… Omarrangér alle tegneflader… + Fit to Artwork Bounds Tilpas til grafikkens grænser + Fit to Selected Art Tilpas til valgt grafik + Graph Diagram + Type… Type… + Data… Data… + Design… Design… + Graph Design Diagramdesign + Graph Marker Diagrammarkør + Column… Kolonne… + Marker… Markør… + Collect for Export Saml til eksport + As Single Asset Som ét aktiv + As Multiple Assets Som flere aktiver + Plug-ins Plug-ins + No filter plug-ins installed Ingen filter-plug-ins installeret + Install Plug-in… Installér plug-in… + Reload Plug-ins Genindlæs plug-ins + Type Tekst + Font Skrifttype + Recent Fonts Seneste skrifttyper + Size Størrelse + Glyphs Glyffer + Insert Special Character Indsæt specialtegn + Bullet Kugletegn + Copyright Symbol Copyrighttegn + Ellipsis Udeladelsesprikker + Paragraph Symbol Afsnitstegn + Registered Trademark Symbol Registreret varemærketegn + Section Symbol Paragraftegn + Trademark Symbol Varemærketegn + Em Dash Lang tankestreg + En Dash Kort tankestreg + Discretionary Hyphen Betinget bindestreg + Nonbreaking Hyphen Fast bindestreg + Double Left Quotation Marks Dobbelt venstre anførselstegn + Double Right Quotation Marks Dobbelt højre anførselstegn + Single Left Quotation Mark Enkelt venstre anførselstegn + Single Right Quotation Mark Enkelt højre anførselstegn + Insert Whitespace Character Indsæt blanktegn + Em Space Em-mellemrum + En Space En-mellemrum + Hair Space Hårfint mellemrum + Sixth Space Sjettedelsmellemrum + Thin Space Tyndt mellemrum + Nonbreaking Space Fast mellemrum + Figure Space Talmellemrum + Punctuation Space Tegnsætningsmellemrum + Third Space Tredjedelsmellemrum + Quarter Space Fjerdedelsmellemrum + Insert Break Character Indsæt skiftetegn + Forced Line Break Tvunget linjeskift + Tab Tabulator + Convert To Area Type Konvertér til områdetekst + Convert To Point Type Konvertér til punkttekst + Area Type Options… Indstillinger for områdetekst… + Type on a Path Tekst på kurve + Area Type Områdetekst + Rainbow Regnbue + Skew Skæv + 3D Ribbon 3D-bånd + Stair Step Trappetrin + Gravity Tyngdekraft + Type on a Path Options… Indstillinger for tekst på kurve… + Threaded Text Sammenkædet tekst + Create Opret + Release Selection Frigør markering + Remove Threading Fjern sammenkædning + Fit Headline Tilpas overskrift + Find Font… Find skrifttype… + Change Case Ændr store/små bogstaver + UPPERCASE STORE BOGSTAVER + lowercase små bogstaver + Title Case Stort bogstav i hvert ord + Sentence case Stort begyndelsesbogstav + Smart Punctuation… Smart tegnsætning… + Create Outlines Opret konturer + Optical Margin Alignment Optisk marginjustering + Fill with Placeholder Text Udfyld med pladsholdertekst + Insert Inline Symbol Indsæt indlejret symbol + Show Hidden Characters Vis skjulte tegn + Bold Fed + Italic Kursiv + Hide Hidden Characters Skjul skjulte tegn + Type Orientation Tekstretning + Horizontal Vandret + Vertical Lodret + Select Markér + All Alt + All on Active Artboard Alt på aktiv tegneflade + Deselect Afmarkér + Reselect Genmarkér + Inverse Invers markering + Next Object Above Næste objekt ovenover + Next Object Below Næste objekt nedenunder + Same Samme + Appearance Udseende + Appearance Attribute Udseendeattribut + Blending Mode Blandingstilstand + Fill & Stroke Fyld og streg + Fill Color Fyldfarve + Opacity Opacitet + Stroke Color Stregfarve + Stroke Weight Stregtykkelse + Graphic Style Grafisk stil + Symbol Instance Symbolforekomst + Font Family Skrifttypefamilie + Font Family & Style Skrifttypefamilie og -stil + Font Family, Style & Size Skrifttypefamilie, stil og størrelse + Font Size Skriftstørrelse + Text Fill Color Tekstfyldfarve + Text Stroke Color Tekststregfarve + All on Same Layers Alt på samme lag + Direction Handles Retningshåndtag + Brush Strokes Penselstrøg + Bristle Brush Strokes Borstepenselstrøg + Clipping Masks Beskæringsmasker + Slices Udsnit + Stray Points Enkeltstående punkter + Open Paths Åbne kurver + All Text Objects Alle tekstobjekter + Point Text Objects Punkttekstobjekter + Area Text Objects Områdetekstobjekter + Start Global Edit Start global redigering + Save Selection… Gem markering… + Edit Selection… Redigér markering… + Effect Effekt + Apply Last Effect Anvend sidste effekt + Last Effect… Sidste effekt… + Document Raster Effects Settings… Indstillinger for rastereffekter i dokumentet… + Vector Effects Vektoreffekter + 3D and Materials 3D og materialer + Extrude & Bevel… Ekstrudér og facet… + Revolve… Omdrej… + Inflate… Pust op… + Materials… Materialer… + Color Adjustments Farvejusteringer + Brightness/Contrast… Lysstyrke/kontrast… + Curves… Kurver… + Hue/Saturation… Farvetone/mætning… + Levels… Niveauer… + Shift to Color… Skift til farve… + Temperature/Tint… Temperatur/toning… + Rectangle… Rektangel… + Rounded Rectangle… Afrundet rektangel… + Ellipse… Ellipse… + Crop Marks Beskæringsmærker + Distort & Transform Forvræng og transformér + Free Distort… Fri forvrængning… + Pucker & Bloat… Krymp og pust op… + Roughen… Gør ru… + Transform… Transformér… + Tweak… Finjustér… + Twist… Vrid… + Zig Zag… Zigzag… + Pathfinder Pathfinder + Add Foren +@id pathfinder.add pathfinder.add + Intersect Skæring + Exclude Udeluk + Subtract Træk fra + Minus Back Minus bagerst + Divide Opdel + Trim Trim + Crop Beskær + Outline Kontur + Stylize Stilisér + Round Corners… Afrund hjørner… + Scribble… Skrible… + Drop Shadow… Slagskygge… + Inner Glow… Indre glød… + Outer Glow… Ydre glød… + Feather… Fjer… + SVG Filters SVG-filtre + Apply SVG Filter… Anvend SVG-filter… + Warp Vridning + Arc… Bue… + Arc Lower… Nedre bue… + Arc Upper… Øvre bue… + Arch… Buegang… + Bulge… Udbulning… + Shell Lower… Nedre skal… + Shell Upper… Øvre skal… + Flag… Flag… + Wave… Bølge… + Fish… Fisk… + Rise… Stigning… + Fisheye… Fiskeøje… + Squeeze… Klem… + Raster Effects Rastereffekter + Blur Sløring + Gaussian Blur… Gaussisk sløring… + View Vis + Preview Forhåndsvisning + Overprint Preview Overprint-forhåndsvisning + Pixel Preview Pixel-forhåndsvisning + Trim View Beskåret visning + Presentation Mode Præsentationstilstand + Screen Mode Skærmtilstand + Normal Screen Mode Normal skærmtilstand + Full Screen Mode with Menu Bar Fuldskærmstilstand med menulinje + Full Screen Mode Fuldskærmstilstand + Proof Setup Korrekturopsætning + Working CMYK Arbejds-CMYK + RGB RGB + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + VectorCraft Generic CMYK (SWOP-like) VectorCraft-generisk CMYK (SWOP-lignende) + Legacy Macintosh RGB (Gamma 1.8) Ældre Macintosh RGB (gamma 1,8) + Internet Standard RGB (sRGB) Internetstandard-RGB (sRGB) + Monitor RGB Skærm-RGB + Color blindness – Protanopia-type Farveblindhed – protanopi-type + Color blindness – Deuteranopia-type Farveblindhed – deuteranopi-type + Customize… Tilpas… + Proof Colors Korrekturfarver + Zoom In Zoom ind + Zoom Out Zoom ud + Fit Artboard in Window Tilpas tegneflade til vindue + Fit All in Window Tilpas alle til vindue + Actual Size Faktisk størrelse + Reset Rotate View Nulstil roteret visning + Hide Edges Skjul kanter + Show Edges Vis kanter + Hide Artboards Skjul tegneflader + Show Artboards Vis tegneflader + Show Print Tiling Vis udskriftopdeling + Hide Print Tiling Skjul udskriftopdeling + Hide Slices Skjul udsnit + Show Slices Vis udsnit + Lock Slices Lås udsnit + Rulers Linealer + Show Rulers Vis linealer + Hide Rulers Skjul linealer + Change to Global Rulers Skift til globale linealer + Show Video Rulers Vis videolinealer + Hide Bounding Box Skjul afgrænsningsramme + Show Bounding Box Vis afgrænsningsramme + Show Transparency Grid Vis gennemsigtighedsgitter + Hide Transparency Grid Skjul gennemsigtighedsgitter + Hide Text Threads Skjul tekstkæder + Show Text Threads Vis tekstkæder + Hide Gradient Annotator Skjul gradientanmærkning + Show Gradient Annotator Vis gradientanmærkning + Hide Corner Widget Skjul hjørnehåndtag + Show Corner Widget Vis hjørnehåndtag + Guides Hjælpelinjer + Hide Guides Skjul hjælpelinjer + Show Guides Vis hjælpelinjer + Lock Guides Lås hjælpelinjer + Unlock Guides Lås hjælpelinjer op + Make Guides Opret hjælpelinjer + Release Guides Frigør hjælpelinjer + Clear Guides Fjern hjælpelinjer + Smart Guides Smarte hjælpelinjer + Perspective Grid Perspektivgitter + Show Grid Vis gitter + Hide Grid Skjul gitter + Snap to Grid Fastgør til gitter + Lock Grid Lås gitter + Unlock Grid Lås gitter op + Lock Station Point Lås stationpunkt + Define Grid… Definér gitter… + One Point Perspective Et-punkts perspektiv + Two Point Perspective To-punkts perspektiv + Three Point Perspective Tre-punkts perspektiv + Save Grid as Preset… Gem gitter som forudindstilling… + Snap to Pixel Fastgør til pixel + Precise Cursors Præcise markører + Snap to Point Fastgør til punkt + Snap to Glyph Fastgør til tegn + New View… Ny visning… + Edit Views… Redigér visninger… + Saved View 1 Gemt visning 1 + Saved View 2 Gemt visning 2 + Saved View 3 Gemt visning 3 + Saved View 4 Gemt visning 4 + Saved View 5 Gemt visning 5 + Saved View 6 Gemt visning 6 + Saved View 7 Gemt visning 7 + Saved View 8 Gemt visning 8 + Saved View 9 Gemt visning 9 + Saved View 10 Gemt visning 10 + Window Vindue + New Window Nyt vindue + Cascade Kaskade + Tile Fliselæg + Float in Window Flyd i vindue + Consolidate All Windows Saml alle vinduer + Workspace Arbejdsområde + Reset Essentials Nulstil Essentials + Reset Automation Nulstil Automation + Reset Essentials Classic Nulstil Essentials Classic + Reset Layout Nulstil Layout + Reset Painting Nulstil Painting + Reset Printing and Proofing Nulstil Printing and Proofing + Reset Tracing Nulstil Tracing + Reset Typography Nulstil Typography + Reset Web Nulstil Web + New Workspace… Nyt arbejdsområde… + Manage Workspaces… Administrér arbejdsområder… + Control Kontrol + Contextual Task Bar Kontekstuel opgavelinje + Pin Bar Position Fastgør linjens placering + Reset Bar Position Nulstil linjens placering + Tools Værktøjer + Toolbars Værktøjslinjer + Advanced Avanceret + Double Column Dobbelt kolonne + Actions Handlinger + Asset Export Aktiveksport + Attributes Attributter + Brushes Pensler + Color Farve + Color Guide Farveguide + Color Themes Farvetemaer + CSS Properties CSS-egenskaber + Document Info Dokumentoplysninger + Flattener Preview Udjævningsforhåndsvisning + Gradient Gradient + Graphic Styles Grafiske stile + Graphic Styles (4) Grafiske stile (4) + History Historik + Info Info + Layers Lag + 1 Layer 1 lag + Libraries Biblioteker + Links Links + {count} linked image(s) changed on disk. Show the new versions? {count} linket billede(r) er ændret på disken. Vis de nye versioner? + Update Changed Links Opdatér ændrede links +@msg Updated {_1} linked file(s) changed on disk Opdaterede {_1} linket fil(er) ændret på disken + Magic Wand Magisk tryllestav + Navigator Navigator + Pattern Options Mønsterindstillinger + Properties Egenskaber + Separations Preview Separationsforhåndsvisning + Stroke Streg + SVG Interactivity SVG-interaktivitet + Swatches Farveprøver + Symbols Symboler + Transparency Gennemsigtighed + Character Tegn + Character Styles Tegnstile + Paragraph Afsnit + Paragraph Styles Afsnitsstile + Tabs Tabulatorer + Variables Variabler + New Variable… Ny variabel… + Unbind Variable Frakobl variabel + Delete Variable Slet variabel + New Data Set… Nyt datasæt… + Edit Data Set… Redigér datasæt… + Delete Data Set Slet datasæt + Next Data Set Næste datasæt + Previous Data Set Forrige datasæt + Make Text Dynamic Gør tekst dynamisk + Make Visibility Dynamic Gør synlighed dynamisk + Capture Data Set Indfang datasæt + Update Data Set Opdatér datasæt + Rename Data Set… Omdøb datasæt… + Variable Options… Variabelindstillinger… + Brush Libraries Penselbiblioteker + Built-in Libraries Indbyggede biblioteker + User Defined Brugerdefinerede + Other Library… Andet bibliotek… + Graphic Style Libraries Biblioteker med grafiske stile + Shadows and Glows Skygger og gløder + Outlines and Rules Konturer og linjer + Hand-Drawn Håndtegnet + Gradient Finishes Gradientfinish + Shape Effects Formeffekter + Blends and Transparency Blandinger og gennemsigtighed + Save Graphic Style Library… Gem bibliotek med grafiske stile… + Swatch Libraries Farveprøvebiblioteker + Default Swatches Standardfarveprøver + Web Safe 216 Web Safe 216 + Grays and Neutrals Gråtoner og neutrale + Earth Tones Jordfarver + Skin Tone Ramps Hudtonegradienter + Pastels Pasteller + Brights Klare farver + Metallic Gradients Metalliske gradienter + Perceptual Scales Perceptuelle skalaer + Harmony Sets Harmonisæt + Gradients Gradienter + Spectrum Gradients Spektrumgradienter + Sky Gradients Himmelgradienter + Neutral Gradients Neutrale gradienter + Duotone Gradients Duotonegradienter + Radial Glows Radiale gløder + Save Swatch Library… Gem bibliotek med farveprøver… + Symbol Libraries Symbolbiblioteker + Help Hjælp + ArtCraft Website ArtCrafts websted + VectorCraft on getartcraft.com VectorCraft på getartcraft.com + VectorCraft on GitHub VectorCraft på GitHub + Search Commands… Søg i kommandoer… + VectorCraft Help… Hjælp til VectorCraft… + Auto Color Autofarve + Automatic Automatisk + Isolate Selected Group Isolér valgt gruppe + Exit Isolation Mode Afslut isoleringstilstand + Make Clipping Mask Opret beskæringsmask + Release Clipping Mask Frigør beskæringsmask + Make Compound Path Opret sammensat kurve + Release Compound Path Frigør sammensat kurve + Select All Markér alt + Collapse Panels to Icons Fold paneler sammen til ikoner + Collapse to Icons Fold sammen til ikoner + Expand Panels Fold paneler ud + About Om + Contributors Bidragsydere + Models Modeller + Username Brugernavn + Display name Vist navn + Real name Rigtigt navn + Sort Sortér + Reverse the order Omvend rækkefølgen + Grab bag Diverse + Table Tabel + Name (A–Z) Navn (A–Å) + Merged PRs Flettede PR'er + Commits Commits + Lines added Linjer tilføjet + Lines deleted Linjer slettet + Line delta Linjeforskel + Binary assets added Binære aktiver tilføjet + Binary assets removed Binære aktiver fjernet + First commit Første commit + Last commit Sidste commit + First Første + Last Sidste + {people} contributors · {commits} commits {people} bidragsydere · {commits} commits + No contributor data was built into this copy. Der blev ikke indbygget bidragsyderdata i denne kopi. + No model credits were built into this copy. Der blev ikke indbygget krediteringer af modeller i denne kopi. + Company Firma + Version Version + % of all commits % af alle commits + Lines +/− Linjer +/− + Drag to move Træk for at flytte + Drag to move out of the set Træk for at flytte ud af sættet + Drag to float as a toolbar Træk for at gøre den flydende som en værktøjslinje + Put back in the toolbar Sæt tilbage på værktøjslinjen + Float Panel Flydende panel + Dock Panel Dok panel + Float Tool Group Flydende værktøjsgruppe + Services Tjenester + Hide VectorCraft Skjul VectorCraft + Hide Others Skjul andre + Minimize Minimér + Zoom Zoom + Bring All to Front Bring alle forrest + Settings Indstillinger + General Generelt + Selection & Anchor Display Markering og visning af ankerpunkter + Units Enheder + Guides & Grid Hjælpelinjer og gitter + Hyphenation Orddeling + Performance & Storage Ydeevne og lager + User Interface Brugergrænseflade + Performance Ydeevne + File Handling Filhåndtering + Clipboard Handling Udklipsholderhåndtering + Appearance of Black Udseende af sort + Devices Enheder + A file dialog is open: choose there first En fildialog er åben: vælg der først + The document was closed while its file dialog was open Dokumentet blev lukket, mens fildialogen var åben + The dialog was closed before its file dialog answered Dialogen blev lukket, før fildialogen svarede + Shaper Shaper + Expand Shaper Group Udvid shapergruppe + Release Shaper Group Frigør shapergruppe + Select Shaper Face Markér shaperflade + Constrain Begræns + Free Transform Fri transformering + Perspective Distort Perspektivforvrængning + Radial Blur… Radial sløring… + Smart Blur… Smart sløring… + Sharpen Skarphed + Unsharp Mask… Uskarp maskering… + Pixelate Pixelering + Color Halftone… Farvehalvtone… + Crystallize… Krystallisér… + Mezzotint… Mezzotint… + Pointillize… Pointillér… + Video Video + De-Interlace… Fjern interlace… + NTSC Colors NTSC-farver + RGB: RGB: + CMYK: CMYK: + CMYK CMYK + Texture Tekstur + Craquelure… Krakelering… + Grain… Korn… + Mosaic Tiles… Mosaikfliser… + Patchwork… Patchwork… + Stained Glass… Blyindfattede ruder… + Texturizer… Teksturér… + System Title Bar Systemtitellinje + Exit Afslut + Distort Forvræng + Diffuse Glow… Diffus glød… + Glass… Glas… + Ocean Ripple… Havkrusning… + Rename Data Set Omdøb datasæt + Rename Variable Omdøb variabel + Activate Panel Aktivér panel + Change Panel Layout Skift panelets layout + Close Panel Luk panel + Move Panel Flyt panel + Group with Gruppér med + Resize panels Tilpas størrelse på paneler + Resize panel window Tilpas størrelse på panelvindue + Lighting Belysning + Revolve angle Omdrejningsvinkel + Axis side Aksside + Rotation X Rotation X + Rotation Y Rotation Y + Rotation Z Rotation Z + Shading Skyggelægning + Intensity Intensitet + Segments Segmenter + Show axis Vis akse + Reset all Nulstil alle + Reset to default Nulstil til standard + Reset all Revolve settings to their defaults. Nulstil alle indstillinger for omdrejning til standardværdierne. + Drag values or labels. Double-click a value to type. Træk i værdier eller etiketter. Dobbeltklik på en værdi for at indtaste. + Drag rings to rotate. Shift snaps to 15°. Træk i ringe for at rotere. Shift fastgør til 15°. + Drag the light, or its rings. Shift snaps to 15°. Træk i lyset eller dets ringe. Shift fastgør til 15°. +lighting Light Lys + Keep visible surfaces only when expanding Behold kun synlige flader ved udvidelse + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Beskær dækket geometri ved Udvid udseende. Levende forhåndsvisning bevarer den fulde flade. Transparente farver og flader, der overskrider synlighedsgrænserne, bevarer alle facetter. + Left Venstre + Right Højre + Offset Forskydning + Rotation Rotation + Cancel Annullér + Accented Edges… Fremhævede kanter… + Angled Strokes… Vinklede strøg… + Crosshatch… Krydsskravering… + Dark Strokes… Mørke strøg… + Ink Outlines… Blækomrids… + Spatter… Stænk… + Sprayed Strokes… Sprøjtede strøg… + Sumi-e… Sumi-e… + Vertical Guide Lodret hjælpelinje + Horizontal Guide Vandret hjælpelinje + Align center Justér mod midten + Align left Justér mod venstre + Align right Justér mod højre + Browser Browser + Color Guide Options… Indstillinger for farveguide… + Color Picker… Farvevælger… + Coming soon — tracked in the parity plan Kommer snart — følges i paritetsplanen + Command Dialog Kommandodialog + Copy Selected Style Kopiér markeret typografi + Corners… Hjørner… + Define Perspective Grid… Definér perspektivgitter… + Delete Workspace Slet arbejdsområde + Document Raster Effects Settings Dialog Dialogboks til indstillinger for rasteriseringseffekter i dokumentet + Drop Shadow Slagskygge + DXF Options Dialog Dialogboks til DXF-indstillinger + Edit Toolbar Redigér værktøjslinjen + Edit Views Redigér visninger + Effect… Effekt… + EPS Options Dialog Dialogboks til EPS-indstillinger + Expand Layers Panel Rows Udvid rækker i lagpanelet + Export CSS… Eksportér CSS… + Export Keyboard Shortcuts… Eksportér tastaturgenveje… + File Info Dialog Dialogboks til filoplysninger + Go to Artboard Gå til tegnebræt + Graphic Style Options… Indstillinger for grafisk typografi… + Symbol Options Symbolindstillinger + Symbol Options… Symbolindstillinger… + Home Home + Import Keyboard Shortcuts… Importér tastaturgenveje… + Justify all lines Justér alle linjer + Justify with last line aligned left Justér med sidste linje venstrejusteret + Keyboard Shortcut Conflicts Konflikter mellem tastaturgenveje + Keyboard Shortcut Set Tastaturgenvejssæt + Interface Language Grænsefladesprog + Last Effect: {name} Sidste effekt: {name} + Last Effect: {} Sidste effekt: {} + Limit Color Guide to Library Begræns farveguide til bibliotek + List Keyboard Shortcuts Vis tastaturgenveje + List Workspaces Vis arbejdsområder + Menu Dialog Menudialog + Merge Graphic Styles… Flet grafiske typografier… + New Color Group… Ny farvegruppe… + New Layer Options… Indstillinger for nyt lag… + New Swatch… Ny farveprøve… + No saved views (View → New View…) Ingen gemte visninger (Visning → Ny visning…) + One Point Perspective Preset 1 1-punkts perspektivforudindstilling 1 + One Point Perspective Preset 2 1-punkts perspektivforudindstilling 2 + One Point Perspective Preset 3 1-punkts perspektivforudindstilling 3 + One Point Perspective Preset 4 1-punkts perspektivforudindstilling 4 + One Point Perspective Preset 5 1-punkts perspektivforudindstilling 5 + Open Recent File 1 Åbn seneste fil 1 + Open Recent File 10 Åbn seneste fil 10 + Open Recent File 11 Åbn seneste fil 11 + Open Recent File 12 Åbn seneste fil 12 + Open Recent File 13 Åbn seneste fil 13 + Open Recent File 14 Åbn seneste fil 14 + Open Recent File 15 Åbn seneste fil 15 + Open Recent File 16 Åbn seneste fil 16 + Open Recent File 17 Åbn seneste fil 17 + Open Recent File 18 Åbn seneste fil 18 + Open Recent File 19 Åbn seneste fil 19 + Open Recent File 2 Åbn seneste fil 2 + Open Recent File 20 Åbn seneste fil 20 + Open Recent File 21 Åbn seneste fil 21 + Open Recent File 22 Åbn seneste fil 22 + Open Recent File 23 Åbn seneste fil 23 + Open Recent File 24 Åbn seneste fil 24 + Open Recent File 25 Åbn seneste fil 25 + Open Recent File 26 Åbn seneste fil 26 + Open Recent File 27 Åbn seneste fil 27 + Open Recent File 28 Åbn seneste fil 28 + Open Recent File 29 Åbn seneste fil 29 + Open Recent File 3 Åbn seneste fil 3 + Open Recent File 30 Åbn seneste fil 30 + Open Recent File 4 Åbn seneste fil 4 + Open Recent File 5 Åbn seneste fil 5 + Open Recent File 6 Åbn seneste fil 6 + Open Recent File 7 Åbn seneste fil 7 + Open Recent File 8 Åbn seneste fil 8 + Open Recent File 9 Åbn seneste fil 9 + Options for Selection… Indstillinger for markering… + Panel Options… Panelindstillinger… + Panels Paneler + Perspective Plane Options… Indstillinger for perspektivplan… + Placement Options… Placeringsindstillinger… + Plug-in… Plug-in… + Preview in Browser Forhåndsvis i browser + Printer Setup… Printeropsætning… + Printers Printere + Recent File Seneste fil + Recent Font Seneste skrifttype + Recent Font 1 Seneste skrifttype 1 + Recent Font 10 Seneste skrifttype 10 + Recent Font 11 Seneste skrifttype 11 + Recent Font 12 Seneste skrifttype 12 + Recent Font 13 Seneste skrifttype 13 + Recent Font 14 Seneste skrifttype 14 + Recent Font 15 Seneste skrifttype 15 + Recent Font 2 Seneste skrifttype 2 + Recent Font 3 Seneste skrifttype 3 + Recent Font 4 Seneste skrifttype 4 + Recent Font 5 Seneste skrifttype 5 + Recent Font 6 Seneste skrifttype 6 + Recent Font 7 Seneste skrifttype 7 + Recent Font 8 Seneste skrifttype 8 + Recent Font 9 Seneste skrifttype 9 + Redo {name} Gendan {name} + Redo {} Gendan {} + Missing Fonts Dialog Dialogboks om manglende skrifttyper + Find Fonts in Folder… Find skrifttyper i mappe… + Rename Workspace Omdøb arbejdsområde + Reset Keyboard Shortcuts Nulstil tastaturgenveje + Reset {name} Nulstil {name} + Reset {} Nulstil {} + Save Document Info… Gem dokumentoplysninger… + Save PDF Dialog Dialogboks til gemning af PDF + Saved Selection 1 Gemt markering 1 + Saved Selection 10 Gemt markering 10 + Saved Selection 11 Gemt markering 11 + Saved Selection 12 Gemt markering 12 + Saved Selection 13 Gemt markering 13 + Saved Selection 14 Gemt markering 14 + Saved Selection 15 Gemt markering 15 + Saved Selection 16 Gemt markering 16 + Saved Selection 17 Gemt markering 17 + Saved Selection 18 Gemt markering 18 + Saved Selection 19 Gemt markering 19 + Saved Selection 2 Gemt markering 2 + Saved Selection 20 Gemt markering 20 + Saved Selection 21 Gemt markering 21 + Saved Selection 22 Gemt markering 22 + Saved Selection 23 Gemt markering 23 + Saved Selection 24 Gemt markering 24 + Saved Selection 25 Gemt markering 25 + Saved Selection 3 Gemt markering 3 + Saved Selection 4 Gemt markering 4 + Saved Selection 5 Gemt markering 5 + Saved Selection 6 Gemt markering 6 + Saved Selection 7 Gemt markering 7 + Saved Selection 8 Gemt markering 8 + Saved Selection 9 Gemt markering 9 + Select Tool Markeringsværktøj + Set Keyboard Shortcut Angiv tastaturgenvej + Set Zoom Angiv zoom + Show Package Vis pakke + Show Panel Vis panel + Spot Colors… Spotfarver… + Swatch Options… Farveprøveindstillinger… + Three Point Perspective Preset 1 3-punkts perspektivforudindstilling 1 + Three Point Perspective Preset 2 3-punkts perspektivforudindstilling 2 + Three Point Perspective Preset 3 3-punkts perspektivforudindstilling 3 + Three Point Perspective Preset 4 3-punkts perspektivforudindstilling 4 + Three Point Perspective Preset 5 3-punkts perspektivforudindstilling 5 + Tool Option Værktøjsindstilling + Tool Options… Værktøjsindstillinger… + Toolbar: Advanced / Basic Værktøjslinje: Avanceret / Grundlæggende + Basic Grundlæggende + Toolbar: Single/Double Column Værktøjslinje: Enkelt/dobbelt kolonne + Toolbar: Add Tool Værktøjslinje: Tilføj værktøj + Toolbar: Remove Tool Værktøjslinje: Fjern værktøj + Toolbar: Reset Værktøjslinje: Nulstil + Two Point Perspective Preset 1 2-punkts perspektivforudindstilling 1 + Two Point Perspective Preset 2 2-punkts perspektivforudindstilling 2 + Two Point Perspective Preset 3 2-punkts perspektivforudindstilling 3 + Two Point Perspective Preset 4 2-punkts perspektivforudindstilling 4 + Two Point Perspective Preset 5 2-punkts perspektivforudindstilling 5 + Undo {name} Fortryd {name} + Undo {} Fortryd {} + User Graphic Style Library 1 Brugerbibliotek med grafiske typografier 1 + User Graphic Style Library 10 Brugerbibliotek med grafiske typografier 10 + User Graphic Style Library 2 Brugerbibliotek med grafiske typografier 2 + User Graphic Style Library 3 Brugerbibliotek med grafiske typografier 3 + User Graphic Style Library 4 Brugerbibliotek med grafiske typografier 4 + User Graphic Style Library 5 Brugerbibliotek med grafiske typografier 5 + User Graphic Style Library 6 Brugerbibliotek med grafiske typografier 6 + User Graphic Style Library 7 Brugerbibliotek med grafiske typografier 7 + User Graphic Style Library 8 Brugerbibliotek med grafiske typografier 8 + User Graphic Style Library 9 Brugerbibliotek med grafiske typografier 9 + User Swatch Library 1 Brugerbibliotek med farveprøver 1 + User Swatch Library 10 Brugerbibliotek med farveprøver 10 + User Swatch Library 2 Brugerbibliotek med farveprøver 2 + User Swatch Library 3 Brugerbibliotek med farveprøver 3 + User Swatch Library 4 Brugerbibliotek med farveprøver 4 + User Swatch Library 5 Brugerbibliotek med farveprøver 5 + User Swatch Library 6 Brugerbibliotek med farveprøver 6 + User Swatch Library 7 Brugerbibliotek med farveprøver 7 + User Swatch Library 8 Brugerbibliotek med farveprøver 8 + User Swatch Library 9 Brugerbibliotek med farveprøver 9 + View {n} Visning {n} + Width Point Edit… Redigér breddepunkt… + to search every command for at søge i alle kommandoer + Anchor Point Ankerpunkt + Anchors: Anker: + Anchors Anker + Click Klik + Alt+Click Alt+klik + Shift+Click Skift+klik + Click an anchor point Klik på et ankerpunkt + Click or drag Klik eller træk + Click the first point Klik på det første punkt + Connect Selected End Points Forbind markerede slutpunkter + Convert Selected Anchor Points to Corner Konvertér markerede ankerpunkter til hjørnepunkter + Convert Selected Anchor Points to Smooth Konvertér markerede ankerpunkter til glatte punkter + Convert: Konvertér: + Double-click Dobbeltklik + Drag Træk + Alt+Drag Alt+træk + Shift+Drag Skift+træk + Drag a handle Træk i et håndtag + Enter Enter + Esc Esc + Fit All Tilpas alle + Fit on Screen Tilpas til skærmen + Help › Search Commands… Hjælp › Søg i kommandoer… + Name* @ 66.67 % (RGB/Preview) Navn* @ 66.67 % (RGB/forhåndsvisning) + Name* @ 66.67% (RGB/Preview) Navn* @ 66.67% (RGB/forhåndsvisning) + No Document Intet dokument + Press Tryk på + Search commands and tools Søg i kommandoer og værktøjer + Style: Typografi: + Switch workspace Skift arbejdsområde + Show all open documents Vis alle åbne dokumenter + Use Brug + the object to select | på objektet for at markere det | + to select multiple objects | for at markere flere objekter | + the object to duplicate på objektet for at duplikere det + an anchor or path segment to select it | på et anker eller stisegment for at markere det | + to move | for at flytte | + to add for at tilføje + to add a corner point | for at tilføje et hjørnepunkt | + to add a smooth point | for at tilføje et glat punkt | + to close | for at lukke | + to finish for at afslutte + to add smooth points | for at tilføje glatte punkter | + to toggle corner | for at slå hjørne til/fra | + to create point type | for at oprette punkttekst | + to create area type | for at oprette områdetekst | + to exit editing for at afslutte redigering + to draw | for at tegne | + to constrain proportions | for at fastholde proportionerne | + from center | fra midten | + for exact size til præcis størrelse + to set the reference point | for at angive referencepunktet | + to transform | for at transformere | + for exact values til præcise værdier + to pan the view for at panorere visningen + to zoom in | for at zoome ind | + to zoom out | for at zoome ud | + to zoom into an area for at zoome ind på et område + Hold Hold + right or left to zoom in or out | til højre eller venstre for at zoome ind eller ud | + to keep zooming for at fortsætte med at zoome + an object to copy its attributes | på et objekt for at kopiere dets attributter | + to apply the selection's to it | for at anvende markeringens på det | + to sample a color for at tage en farveprøve + across a selected object to set the gradient direction hen over et markeret objekt for at angive gradientretningen + to select an artboard | for at markere et tegnebræt | + on the canvas to create one på canvaset for at oprette et + a stroke to add a width point | på en kontur for at tilføje et breddepunkt | + to change one side | for at ændre den ene side | + to select more points | for at markere flere punkter | + a point to edit it | på et punkt for at redigere det | + to remove points for at fjerne punkter + the art to add a pin | på grafikken for at tilføje en nål | + to select more pins | for at markere flere nåle | + a pin to warp | på en nål for at forvrænge | + near a selected pin to rotate | tæt på en markeret nål for at rotere | + to remove pins for at fjerne nåle + across paths to push them along | hen over stier for at skubbe dem fremad | + to size the brush ( for at ændre penslens størrelse ( + keeps its proportions) bevarer dens proportioner) + over paths, and hold still to keep going | hen over stier, og hold den i ro for at fortsætte | + over paths to roughen their outlines | hen over stier for at gøre deres omrids mere ru | + to move in perspective | for at flytte i perspektiv | + to copy | for at kopiere | + while dragging to move perpendicular to the plane | mens du trækker, for at flytte vinkelret på planet | + to scale in perspective for at skalere i perspektiv + an object, then another to blend them | på et objekt og derefter et andet for at blande dem | + to blend from it | for at blande fra det | + to set spacing and orientation for at angive afstand og orientering + Apply Anvend + Change Screen Mode (F) Skift skærmtilstand (F) + Color (,) Farve (,) + Draw Behind Tegn bagved + Draw Inside Tegn indeni + Draw Normal Tegn normalt + Expand: Udvid: + Gradient (.) Gradient (.) + H: H: + Modify Ændr + Navigate Navigér + None (/) Ingen (/) + Select All Pins Markér alle nåle + Show Mesh Vis mesh + Toggle single/double column Skift enkelt/dobbelt kolonne + W: W: + X: X: + Y: Y: + Drag to float the toolbar Træk for at frigøre værktøjslinjen + Drag to the window's left edge to dock Træk til vinduets venstre kant for at fastgøre + Crystallize Krystallisér + Add Anchor Point Tool Tilføj ankerpunkt-værktøj + Anchor Point Tool Ankerpunktværktøj + Arc Tool Bueværktøj + Area Graph Tool Områdediagramværktøj + Area Type Tool Områdetekstværktøj + Artboard Tool Tegnebrætværktøj + Bar Graph Tool Bjælkediagramværktøj + Blend Tool Blandingsværktøj + Bloat Tool Oppustningsværktøj + Blob Brush Tool Blobpenselværktøj + Column Graph Tool Søjlediagramværktøj + Crystallize Tool Krystalliseringsværktøj + Delete Anchor Point Tool Slet ankerpunkt-værktøj + Direct Selection Tool Direkte markeringsværktøj + Direct Selection Direkte markering + Ellipse Tool Ellipseværktøj + Eraser Tool Viskelæderværktøj + Eyedropper Tool Pipetteværktøj + Flare Tool Flareværktøj + Free Transform Tool Fri transformeringsværktøj + Gradient Tool Gradientværktøj + Group Selection Tool Gruppemarkeringværktøj + Group Selection Gruppemarkering + Hand Tool Håndværktøj + Hand Hånd + Join Tool Sammenføjningsværktøj + Lasso Tool Lassoværktøj + Lasso Lasso + Line Cut Tool Linjeskæringsværktøj + Line Graph Tool Linjediagramværktøj + Line Segment Tool Linjesegmentværktøj + Live Paint Selection Tool Markeringsværktøj til Live Paint + Magic Wand Tool Tryllestavsværktøj + Measure Tool Måleværktøj + Measure Mål + Mesh Tool Meshværktøj + Mirror & Cut Tool Spejl- og skæringsværktøj + Paintbrush Tool Penselværktøj + Path Eraser Tool Stiviskelæderværktøj + Pencil Tool Blyantsværktøj + Perspective Grid Tool Perspektivgitterværktøj + Perspective Selection Tool Perspektivmarkeringsværktøj + Pie Graph Tool Cirkeldiagramværktøj + Polar Grid Tool Polært gitterværktøj + Polygon Tool Polygonværktøj + Print Tiling Tool Udskriftsopdelingsværktøj + Pucker Tool Krympningsværktøj + Puppet Warp Tool Dukkeforvrængningsværktøj + Radar Graph Tool Radardiagramværktøj + Rectangle Cut Tool Rektangelskæringsværktøj + Rectangle Tool Rektangelværktøj + Rectangular Grid Tool Rektangulært gitterværktøj + Reflect Tool Spejlingsværktøj + Reshape Tool Omformningsværktøj + Rotate Tool Roteringsværktøj + Rotate View Tool Visningsroteringsværktøj + Rotate View Rotér visning + Rounded Rectangle Tool Afrundet rektangelværktøj + Scale Tool Skaleringsværktøj + Scallop Tool Bølgekantværktøj + Scatter Graph Tool Spredningsdiagramværktøj + Scissors Tool Saksværktøj + Scissors Saks + Selection Tool Markeringsværktøj + Shape Builder Tool Figurbyggerværktøj + Shaper Tool Shaper-værktøj + Shear Tool Vrideværktøj + Slice Selection Tool Udsnitsmarkeringsværktøj + Slice Tool Udsnitsværktøj + Smooth Tool Udglatningsværktøj + Spiral Tool Spiralværktøj + Stacked Bar Graph Tool Stablet bjælkediagramværktøj + Stacked Column Graph Tool Stablet søjlediagramværktøj + Star Tool Stjerneværktøj + Symbol Screener Tool Symbolgennemsigtighedsværktøj + Symbol Scruncher Tool Symbolsamlingsværktøj + Symbol Shifter Tool Symbolflytteværktøj + Symbol Sizer Tool Symbolstørrelsesværktøj + Symbol Spinner Tool Symbolroteringsværktøj + Symbol Sprayer Tool Symbolsprøjtningsværktøj + Symbol Stainer Tool Symbolfarvningsværktøj + Symbol Styler Tool Symboltypografiværktøj + Touch Type Tool Touch Type-værktøj + Touch Type Touch Type + Twirl Tool Hvirvelværktøj + Type on a Path Tool Tekst på sti-værktøj + Type Tool Tekstværktøj + Vertical Area Type Tool Lodret områdetekstværktøj + Vertical Area Type Lodret områdetekst + Vertical Type on a Path Tool Lodret tekst på sti-værktøj + Vertical Type on a Path Lodret tekst på sti + Vertical Type Tool Lodret tekstværktøj + Vertical Type Lodret tekst + Warp Tool Forvrængningsværktøj + Width Tool Breddeværktøj + Wrinkle Tool Rynkeværktøj + Zoom Tool Zoomværktøj + Action {n} Handling {n} + Begin Recording Start optagelse + Center on Artboard Centrér på tegnebræt + Default Actions Standardhandlinger + ▾ Default Actions ▾ Standardhandlinger + Duplicate and Offset 10 pt Duplikér og forskyd 10 pt + No actions. Press ● to record. Ingen handlinger. Tryk på ● for at optage. + Outline Text Tekst til konturer + Play Current Selection Afspil aktuel markering + Reflect Horizontal Spejl vandret + Rotate 90° CW Rotér 90° med uret + Set 1 Sæt 1 + Stop Playing/Recording Stop afspilning/optagelse + Unite and Simplify Foren og simplificér + ● Recording ● Optager + Align to Artboard Juster til tegnebræt + Align to Glyph Bounds Juster til glyfafgrænsning + Align to Key Object Juster til nøgleobjekt + Align to Selection Juster til markering + Align To: Juster til: + Cancel Key Object Annullér nøgleobjekt + Distribute Objects: Fordel objekter: + Distribute Spacing: Fordel mellemrum: + Horizontal Distribute Center Fordel vandret efter midten + Horizontal Distribute Left Fordel vandret efter venstre + Horizontal Distribute Right Fordel vandret efter højre + Horizontal Distribute Space Vandret mellemrumsfordeling + Vertical Distribute Bottom Fordel lodret efter bunden + Vertical Distribute Center Fordel lodret efter midten + Vertical Distribute Space Lodret mellemrumsfordeling + Vertical Distribute Top Fordel lodret efter toppen + Add New Effect Tilføj ny effekt + Click to choose a swatch, Shift-click to mix a colour Klik for at vælge en farveprøve, Skift-klik for at blande en farve + Click to toggle visibility Klik for at slå synlighed til/fra + Dashed Stiplet + Delete Selected Item Slet markeret element + Double-click to edit the characters' paint Dobbeltklik for at redigere tegnenes maling + Double-click to target the contents Dobbeltklik for at målrette indholdet + Drag onto art to apply this appearance Træk hen over grafikken for at anvende dette udseende + Duplicate Selected Item Duplikér markeret element + Fill: Fyld: + Hide Thumbnail Skjul miniatur + Mixed Appearances Blandede udseender + Mixed Objects Blandede objekter + No options Ingen indstillinger + No Selection Ingen markering + Offset X Forskydning X + Opacity: 50% Multiply Opacitet: 50% Multiplicer + Opacity: Default Opacitet: Standard + Opacity: {value} Opacitet: {value} + Rectangle: Sunshine Rektangel: Sunshine + Redefine Graphic Style “{name}” Omdefinér grafisk typografi »{name}« + Show Thumbnail Vis miniatur + Artboard Options: edit with the Artboard tool Tegnebrætindstillinger: redigér med tegnebrætværktøjet + Delete Artboards Slet tegnebrætter + Delete Empty Artboards Slet tomme tegnebrætter + Duplicate Artboards Duplikér tegnebrætter + Move Down Flyt ned + Move Up Flyt op + Open a document to see its artboards. Åbn et dokument for at se dets tegnebrætter. + Rearrange All Artboards Omarrangér alle tegnebrætter + Move Artwork with Artboard Flyt grafik med tegnebræt + Scale Artwork with Artboard Skalér grafik med tegnebræt + Add Selected Artwork (Alt: as one asset) Tilføj markeret grafik (Alt: som ét aktiv) + Add Selected Artwork as Multiple Assets Tilføj markeret grafik som flere aktiver + Double-click to rename Dobbeltklik for at omdøbe + Drag art here, or select it and click +. Hold Alt for one asset of several objects. Træk grafik hertil, eller markér den, og klik på +. Hold Alt nede for ét aktiv bestående af flere objekter. + Export the selected assets (else all of them) Eksportér de markerede aktiver (ellers alle sammen) + Export {count} Selected Eksportér {count} markerede + Select All Assets Markér alle aktiver + Don't Show Center Vis ikke midtpunkt + Hide Note Skjul note + Image Map: Billedkort: + Note: Note: + Open the URL in the web browser Åbn URL'en i webbrowseren + Overprint Fill Overtryksfyld + Overprint Stroke Overtrykskontur + Recent URLs Seneste URL'er + Reverse Path Direction Off Omvend stiretning fra + Reverse Path Direction On Omvend stiretning til + Show Center Vis midtpunkt + Show Note Vis note + URL: URL: + Use Even-Odd Fill Rule Brug fyldreglen lige-ulige + Use Non-Zero Winding Fill Rule Brug fyldreglen ikke-nul-vikling + Brush Libraries (on the roadmap) Penselbiblioteker (på vejkortet) + List View Listevisning + New Art Brush from Selection Ny kunstpensel fra markering + New Bristle Brush Ny bustpensel + New Calligraphic Brush Ny kalligrafisk pensel + New Pattern Brush from Selection Ny mønsterpensel fra markering + New Scatter Brush from Selection Ny spredningspensel fra markering + No brushes Ingen pensler + Select art and use New Brush to make one. Markér grafik, og brug Ny pensel for at oprette en. + Show {kind} Brushes Vis {kind}-pensler + Thumbnail View Miniaturvisning + All Caps Alt i versaler + Character Alignment Tegnjustering + Character Rotation Tegnrotation + Em Box Bottom/Left Em-felt nederst/venstre + Em Box Center Em-felt midt + Em Box Top/Right Em-felt øverst/højre + Fractional Widths Fraktionerede bredder + Hide Options Skjul indstillinger + Horizontal Scale % Vandret skalering % + Kerning (0 = Auto) Kerning (0 = automatisk) + Leading Linjeafstand + No Break Ingen ombrydning + Proportional Metrics Proportionale metrikker + Digits Cifre + Arabic Arabisk + Hindi Hindi + Farsi Farsi + Reset Panel Nulstil panel + Roman Baseline Romersk grundlinje + Select a text object to edit its character attributes. Markér et tekstobjekt for at redigere dets tegnattributter. + Show Options Vis indstillinger + Small Caps Kapitæler + Small Caps (on the roadmap) Kapitæler (på vejkortet) + Standard Vertical Roman Alignment Standard lodret romersk justering + Strikethrough Gennemstreget + Character: Tegn: + Character options Tegnindstillinger + Subscript Sænket skrift + Subscript (on the roadmap) Sænket skrift (på vejkortet) + Superscript Hævet skrift + Superscript (on the roadmap) Hævet skrift (på vejkortet) + System Layout Systemlayout + Tate-chu-yoko Tate-chu-yoko + Underline Understreget + Vertical Scale % Lodret skalering % + Axis Akse + ICF Top/Right ICF øverst/højre + ICF Bottom/Left ICF nederst/venstre + Copy Color Value (Hex) Kopiér farveværdi (hex) + Create New Swatch… Opret ny farveprøve… + Name 40% Navn 40% + Out of Gamut Warning Advarsel: uden for gamut + Out of Web Color Warning Advarsel: ikke-websikker farve + Web Safe RGB Websikker RGB + {warning}: click to correct to the closest color ({hex}) {warning}: klik for at rette til den nærmeste farve ({hex}) + Edit or Apply Colors Redigér eller anvend farver + Limit colors to swatch library Begræns farver til farveprøvebibliotek + Limit colors to swatch library: {name} Begræns farver til farveprøvebibliotek: {name} + Save color group to Swatch panel Gem farvegruppe i farveprøvepanelet + Save Colors as Swatches Gem farver som farveprøver + Save selected colors as swatches Gem markerede farver som farveprøver + Set base color Angiv basisfarve + Set base color to the current color Sæt basisfarven til den aktuelle farve + Show Tints/Shades Vis toner/skygger + Show Vivid/Muted Vis klare/dæmpede + Show Warm/Cool Vis varme/kolde + Click to select, double-click to edit Klik for at markere, dobbeltklik for at redigere + Color Theme Farvetema + Delete theme Slet tema + Edit Theme Redigér tema + Edit theme Redigér tema + Make a theme on Create and save it. Opret et tema på Create, og gem det. + My Themes Mine temaer + New theme from the current color Nyt tema fra den aktuelle farve + No saved themes Ingen gemte temaer + Rule: Regel: + Save changes to {name} Gem ændringer i {name} + Save theme to My Themes Gem tema i Mine temaer + Set the selected color to the current color Sæt den markerede farve til den aktuelle farve + Copied the CSS of {rules} object(s) Kopierede CSS'en for {rules} objekt(er) + Copy All Styles Kopiér alle typografier + Exported {path} Eksporterede {path} + Generate CSS: the whole document's CSS while nothing is selected Generér CSS: hele dokumentets CSS, når intet er markeret + No CSS Ingen CSS + Select objects to see their CSS, or Generate CSS for the whole document. Markér objekter for at se deres CSS, eller generér CSS for hele dokumentet. + The selected objects have no CSS of their own (see below). De markerede objekter har ingen egen CSS (se nedenfor). + Units: {units} Enheder: {units} + {count} more rules: Export writes every one. {count} flere regler: Eksport skriver hver enkelt. + {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} unavngivne objekter udeladt: giv dem navne, eller aktivér Unavngivne objekter i panelmenuen. + All Categories Alle kategorier + Save… Gem… + Selection Only Kun markering + {title} (selection) {title} (markering) + Changed since the last Refresh. Ændret siden sidste opdatering. + Click to zoom in, Alt-click to zoom out, drag to pan, double-click to fit Klik for at zoome ind, Alt-klik for at zoome ud, træk for at panorere, dobbeltklik for at tilpasse + Fit in Window Tilpas i vindue + Open a document to preview its flattening. Åbn et dokument for at forhåndsvise dets fladtrykning. + Overprints: Overtryk: + Preview the document as it is now Forhåndsvis dokumentet, som det er nu + Raster/Vector Balance: Raster/vektor-balance: + Refresh Opdatér + Save Transparency Flattener Preset… Gem forudindstilling til fladtrykning af gennemsigtighed… + Basic Latin Grundlæggende latinsk + Currency & Symbols Valuta & symboler + Double-click to insert Dobbeltklik for at indsætte + Entire Font Hele skrifttypen + glyph {id} glyf {id} + Greek & Cyrillic Græsk & kyrillisk + Latin Extended Udvidet latinsk + Latin-1 Supplement Latin-1-supplement + No fonts are available. Ingen skrifttyper er tilgængelige. + Select text or click into text with the Type tool to insert glyphs Markér tekst, eller klik i teksten med tekstværktøjet, for at indsætte glyffer + Show: Vis: + Use the Selected Text's Font Brug den markerede teksts skrifttype + Aspect Ratio Sideforhold + Click the art with the Gradient tool to add or select points. Klik på grafikken med gradientværktøjet for at tilføje eller markere punkter. + Click the ramp or a type button to apply a gradient. Klik på rampen eller en typeknap for at anvende en gradient. + Color: Farve: + Delete Stop Slet stop + Edit Gradient Redigér gradient + Edit the selected point's colour in the Color panel Redigér det markerede punkts farve i farvepanelet + Edit the stop Redigér stoppet + Eyedropper: click the art to sample the stop's colour Pipette: klik på grafikken for at prøvetage stoppets farve + Gradient across stroke Gradient på tværs af konturen + Gradient along stroke Gradient langs konturen + Gradient Fill: click to apply, drag onto art Gradientfyld: klik for at anvende, træk hen over grafikken + Gradient swatches Gradientfarveprøver + Gradient within stroke Gradient inde i konturen + Linear Gradient Lineær gradient + Lines: clicks add points joined by a line Linjer: klik tilføjer punkter, der forbindes med en linje + No gradient swatches Ingen gradientfarveprøver + Points: clicks add free points Punkter: klik tilføjer frie punkter + Radial Gradient Radial gradient + Reset to White, Black Nulstil til hvid, sort + Reverse Gradient Omvend gradient + Save to Swatches Gem i farveprøver + Delete Graphic Style (Alt-click: without asking) Slet grafisk typografi (Alt-klik: uden at spørge) + Delete the graphic style “{name}”? Slet den grafiske typografi »{name}«? + Delete these {n} graphic styles? Slet disse {n} grafiske typografier? + Delete this {n} graphic style? Slet denne {n} grafiske typografi? + Graphic Style Libraries Menu Menu med biblioteker af grafiske typografier + New Graphic Style (Alt-click to name it) Ny grafisk typografi (Alt-klik for at navngive den) + New Graphic Style… Ny grafisk typografi… + No graphic styles Ingen grafiske typografier + Objects using them keep their look but are no longer linked. Objekter, der bruger dem, bevarer deres udseende, men er ikke længere tilknyttet. + Open a document to see its graphic styles. Åbn et dokument for at se dets grafiske typografier. + Open Graphic Style Library Åbn bibliotek med grafiske typografier + Select styled art and click New Graphic Style. Markér grafik med typografi, og klik på Ny grafisk typografi. + Use Square for Previews Brug kvadrat til forhåndsvisninger + Use Text for Previews Brug tekst til forhåndsvisninger + {percent}% opacity {percent}% opacitet + History Options… Indstillinger for historik… + Open a document to see its history. Åbn et dokument for at se dets historik. + Abutting Stødende + Black and White Sort-hvid + Corners Hjørner + Auto Grouping Autogruppering + Enhanced Forbedret + Ignore Color Ignorér farve + Ignore White Ignorér hvid + Palette: Palet: + Limited Begrænset + Full Tone Fuld tone + Document Library Dokumentbibliotek + Library: Bibliotek: + Tracing Presets Vektoriseringsforudindstillinger + Method: Metode: + Mode: Tilstand: + Noise Støj + Create: Opret: + Fills Udfyldninger + Strokes Konturer + Overlapping Overlappende + Paths: {paths} Anchors: {anchors} Colors: {colors} Stier: {paths} Anker: {anchors} Farver: {colors} + Reset to Default Nulstil til standard + Select an image to trace Markér et billede at vektorisere + Snap Curves To Lines Fastgør kurver til linjer + Tracing Result with Outlines Vektoriseringsresultat med konturer + Source Image Kildebillede + Threshold Tærskel + Trace Vektorisér + Trace the image with the Default preset Vektorisér billedet med standardforudindstillingen + Size: {size} Tracking: {tracking} Størrelse: {size} Sporing: {tracking} + + Create New Layer Opret nyt lag + Create New Sublayer Opret nyt underlag + Search All Søg i alle + Graphics Grafik + Local Libraries Lokale biblioteker + Add Content Tilføj indhold + Click to apply to the selected type Klik for at anvende på den markerede tekst + Create New Library… Opret nyt bibliotek… + Rename Library… Omdøb bibliotek… + Delete the library “{name}”? Slet biblioteket »{name}«? + Drag art or swatches here, or use + to add the selection's colors and text styles. Træk grafik eller farveprøver hertil, eller brug + for at tilføje markeringens farver og teksttypografier. + Its graphics, colors and text styles are deleted from this machine. Dens grafik, farver og teksttypografier slettes fra denne maskine. + Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. Opbevar grafik, farver og teksttypografier her for at bruge dem i alle dokumenter. Biblioteker gemmes på denne maskine — der kræves ingen konto. + This library is empty Dette bibliotek er tomt + No results Ingen resultater + No items in this library match “{query}”. Ingen elementer i dette bibliotek matcher »{query}«. + Right-click an item to move it into this group. Højreklik på et element for at flytte det til denne gruppe. + New Group Ny gruppe + New Group… Ny gruppe… + Move to Group Flyt til gruppe + No Group Ingen gruppe + Rename Group… Omdøb gruppe… + Delete Group Slet gruppe + Import Library… Importér bibliotek… + Export Library… Eksportér bibliotek… + Close Library Luk bibliotek + Next Library Næste bibliotek + No matches. Ingen match. + Other Libraries Andre biblioteker + Previous Library Forrige bibliotek + Swatch exchange files keep solid colors only. Farveudvekslingsfiler kan kun indeholde flade farver. + This library is empty. Dette bibliotek er tomt. + Color Space Farverum + Created Oprettet + Dimensions Dimensioner + Embedded {n} image(s) Indlejret {n} billede(r) + Embedded {n} image(s); {missing} missing: relink them first Indlejret {n} billede(r); {missing} mangler: sammenkæd dem igen først + File › Place… brings in an image, linked or embedded. Filer › Placér… henter et billede ind, sammenkædet eller indlejret. + Format Format + Medium Thumbnails Mellemstore miniaturer + Missing: {path} Mangler: {path} + Modified Ændret + Modified since it was read: {path} Ændret siden den blev læst: {path} + No Thumbnails Ingen miniaturer + Nothing matches the Show filter. Intet matcher Vis-filteret. + PPI PPI + Relinked {n} image(s) Sammenkædet {n} billede(r) igen + Relinked {n} image(s); not in that folder: {} Sammenkædet {n} billede(r) igen; ikke i den mappe: {} + Relinked {} image(s) Sammenkædet {} billede(r) igen + Relink… Sammenkæd igen… + Find Linked Files Find sammenkædede filer + Find Linked Files in Folder… Find sammenkædede filer i mappe… + Choose the file to relink each linked file to. Files with the size and contents saved with the link come first. Vælg den fil, som hver sammenkædet fil skal sammenkædes med igen. Filer med den størrelse og det indhold, der er gemt med sammenkædningen, vises først. + Same file Samme fil + Same size Samme størrelse + Don't Relink Sammenkæd ikke igen + Copy into the document's folder Kopiér ind i dokumentets mappe + Select an image to see its Link Info. Markér et billede for at se dets sammenkædningsoplysninger. + Show Embedded Vis indlejrede + Show Link Info Vis sammenkædningsoplysninger + Show Modified Vis ændrede + Small Thumbnails Små miniaturer + Status Status + Unembedded to {} Taget ud af indlejring til {} + Update Link Opdatér sammenkædning + Updated {} image(s) Opdaterede {} billede(r) + Break Link Bryd sammenkædning + Tolerance: Tolerance: + Transparency Options Gennemsigtighedsindstillinger + Color Mixer (Shift-click the chip) Farveblander (Skift-klik på chippen) + Fill: click for swatches, Shift-click for the color mixer Fyld: klik for farveprøver, Skift-klik for farveblanderen + Stroke: click for swatches, Shift-click for the color mixer Kontur: klik for farveprøver, Skift-klik for farveblanderen + This panel is on the roadmap (see the parity plan). Dette panel er på vejkortet (se paritetsplanen). + Open a document to navigate it. Åbn et dokument for at navigere i det. + View Artboard Only Vis kun tegnebræt + Contextual Alternates Kontekstuelle alternativer + Default Figure Standardcifre + Discretionary Ligatures Valgfrie ligaturer + Figure: Cifre: + Fractions Brøker + Ligatures are off while the text is tracked. Ligaturer er slået fra, mens der er sporing på teksten. + Ordinals Ordenstal + Proportional Oldstyle Proportionale mediævalcifre + Reset OpenType Features Nulstil OpenType-funktioner + Select text to set its OpenType features. Markér tekst for at angive dens OpenType-funktioner. + Standard Ligatures Standardligaturer + Swash Sving + Tabular Lining Tabulære versalcifre + Tabular Oldstyle Tabulære mediævalcifre + Every-line Composer Hverlinjesætter + First-line Left Indent Venstre indrykning af første linje + Hyphenation… Orddeling… + Justification… Justering… + Justify with last line aligned center Justér med sidste linje centreret + Justify with last line aligned right Justér med sidste linje højrejusteret + Left Indent Venstre indrykning + Left-to-Right Paragraph Direction Afsnitsretning fra venstre mod højre + Line-end Punctuation Half Width Skilletegn i halv bredde ved linjeslut + Mojikumi Set Mojikumi-sæt + Kinsoku Set Kinsoku-sæt + Hard Hård + Right Indent Højre indrykning + Right-to-Left Paragraph Direction Afsnitsretning fra højre mod venstre + Roman Hanging Punctuation Romersk hængende tegnsætning + Select a text object to edit its paragraph attributes. Markér et tekstobjekt for at redigere dets afsnitsattributter. + Single-line Composer Enlinjesætter + Space After Paragraph Mellemrum efter afsnit + Space Before Paragraph Mellemrum før afsnit + Top-to-Top Leading Linjeafstand top til top + Bottom-to-Bottom Leading Linjeafstand bund til bund + Burasagari Burasagari + Force Tving + Expand applies to compound shapes (Alt-click a shape mode) — on the roadmap Udvid gælder sammensatte figurer (Alt-klik på en figurtilstand) — på vejkortet + Expand Compound Shape Udvid sammensat figur + Make Compound Shape Opret sammensat figur + Pathfinder Options… Pathfinder-indstillinger… + Pathfinders: Pathfindere: + Release Compound Shape Frigør sammensat figur + Repeat Pathfinder Gentag Pathfinder + Repeat {name} Gentag {name} + Trap… Trap… + Bottom in Front Bund forrest + Brick by Column Mursten efter kolonne + Brick by Row Mursten efter række + Brick Offset: Murstensforskydning: + Copies: Kopier: + Dim Copies to: Dæmp kopier til: + H Spacing: Vandret afstand: + Height: Højde: + Hex by Column Sekskant efter kolonne + Hex by Row Sekskant efter række + Left in Front Venstre forrest + No document Intet dokument + Open a document to edit its patterns. Åbn et dokument for at redigere dets mønstre. + Overlap: Overlap: + Pattern options are available in pattern editing mode. Mønsterindstillinger er tilgængelige i mønsterredigeringstilstand. + Right in Front Højre forrest + Select art and choose Object › Pattern › Make. Markér grafik, og vælg Objekt › Mønster › Opret. + Show Swatch Bounds Vis farveprøvegrænser + Show Tile Edge Vis flisekant + Size Tile to Art Tilpas flise til grafik + Tile Type: Flisetype: + Top in Front Toppen forrest + V Spacing: Lodret afstand: + Width: Bredde: + Appearance panel Udseende-panelet + Arrange: Bring to Front Arrangér: Placér forrest + Draw: Tegn: + Edit Artboards Redigér tegnebrætter + Flip Along Horizontal Axis Spejlvend om vandret akse + Flip Along Vertical Axis Spejlvend om lodret akse + Hyphenate Orddeling + Isolate Group Isolér gruppe + Landscape Liggende + More Pathfinder options Flere Pathfinder-indstillinger + More Align options Flere justeringsindstillinger + More Paragraph options Flere afsnitsindstillinger + No document open Intet åbent dokument + Quick Actions Hurtige handlinger + Rearrange All Omarrangér alle + Rulers & Grids Linealer og gitre + Select a text object Markér et tekstobjekt + Show Grid (⌘') Vis gitter (⌘') + Show Rulers (⌘R) Vis linealer (⌘R) + Smart Guides (⌘U) Smarte hjælpelinjer (⌘U) + Snap Options Fastgørelsesindstillinger + Type: Type: + All colours are within the CMYK gamut Alle farver er inden for CMYK-farveomfanget + Assign: Tildel: + Color Settings Farveindstillinger + Gamut Check Farveomfangskontrol + Intent: Hensigt: + No document open. Intet åbent dokument. + Overprint Preview (separations) Forhåndsvisning af overtryk (separationer) + Show All Plates Vis alle plader + Show all plates Vis alle plader + Show/hide plate (Alt-click: only this plate) Vis/skjul plade (Alt-klik: kun denne plade) + Simulate Paper Simulér papir + Use Black Point Compensation Brug sortpunktskompensation + Working CMYK (don't tag) Arbejds-CMYK (mærk ikke) + {name} (spot) {name} (spotfarve) + Add to Profiles… Tilføj til profiler… + Align Stroke to Center Justér kontur til midten + Align Stroke to Inside Justér kontur til indersiden + Align Stroke to Outside Justér kontur til ydersiden + Align Stroke: Justér kontur: + Align: Justér: + Arrowheads: Pilespidser: + Bevel Join Facet samling + Butt Cap Afkortet afslutning + Cap: Afslutning: + Corner: Hjørne: + End arrowhead Slutpilespids + Exact dash lengths Nøjagtige streglængder + Fit dashes to corners and ends Tilpas streger til hjørner og ender + Flip Across Spejl på tværs + Flip Along Spejl langs + gap mellemrum + Limit: Grænse: + Link start and end arrowhead scales Sammenkæd skalaen for start- og slutpilespids + Miter Join Spids samling + Profile: Profil: + Projecting Cap Fremspringende afslutning + Round Cap Rund afslutning + Round Join Rund samling + Scale: Skala: + Start arrowhead Startpilespids + Stroke options Konturindstillinger + Swap start and end arrowheads Byt om på start- og slutpilespids + Tip extends past the end point Spidsen rager ud over endepunktet + Tip on the end point Spidsen på endepunktet + Variable Width Profile Variabel breddeprofil + Weight: Vægt: + Art using a deleted global swatch keeps its colour. Grafik, der bruger en slettet global farveprøve, bevarer sin farve. + Color Group: {name} Farvegruppe: {name} + Delete these {count} swatches and groups? Slet disse {count} farveprøver og grupper? + Delete “{name}”? Slet »{name}«? + Document Swatches Dokumentfarveprøver + Edit or Apply Color Group Redigér eller anvend farvegruppe + Edit or Apply Color Group… Redigér eller anvend farvegruppe… + Global Process Color Global procesfarve + Global Process Color, CMYK Global procesfarve, CMYK + Large List View Stor listevisning + Large Thumbnail View Stor miniaturevisning + Medium Thumbnail View Mellemstor miniaturevisning + Open a document to see its swatches. Åbn et dokument for at se dets farveprøver. + Open Swatch Library Åbn farveprøvebibliotek + Process Color Procesfarve + Show All Swatches Vis alle farveprøver + Show Color Groups Vis farvegrupper + Show Color Swatches Vis farveprøver + Show Find Field Vis søgefelt + Show Gradient Swatches Vis gradientprøver + Show List View Vis listevisning + Show Pattern Swatches Vis mønsterprøver + Pattern Swatches Mønsterprøver + Show Swatch Kinds Vis farveprøvetyper + Show Thumbnail View Vis miniaturevisning + Small List View Lille listevisning + Small Thumbnail View Lille miniaturevisning + Swatch Libraries Menu Farveprøvebiblioteksmenu + User Library Brugerbibliotek + {kind} Gradient {kind} gradient + {kind}, {model} {kind}, {model} + No symbols in this document Ingen symboler i dette dokument + Select art and click New Symbol to make one. Markér grafik, og klik på Nyt symbol for at oprette et. + Symbol Libraries (on the roadmap) Symbolbiblioteker (på roadmappen) + Align On: Justér på: + Center-Justified Tab Midterjusteret tabulator + Above Text Over teksten + Position Panel Above Text Placér panel over teksten + Click to add a tab stop, drag to move it, drag it off the ruler to delete it Klik for at tilføje et tabulatorstop, træk for at flytte det, og træk det væk fra linealen for at slette det + Decimal-Justified Tab Decimaljusteret tabulator + Delete Tab Slet tabulator + Leader: Ledetegn: + Left-Justified Tab Venstrejusteret tabulator + No text selected Ingen tekst markeret + Repeat Tab Gentag tabulator + Right-Justified Tab Højrejusteret tabulator + Select a text object to set its tab stops. Markér et tekstobjekt for at angive dets tabulatorstop. + Select text Markér tekst + + Add Scale + Tilføj skala + Add Selected Artwork as One Asset Tilføj markeret grafik som ét aktiv + Export Settings Eksportindstillinger + No assets Ingen aktiver + Remove Selected Assets Fjern markerede aktiver + Suffix Suffiks + Absolute Position Absolut position + Export All… Eksportér alle… + Export Selected CSS… Eksportér markeret CSS… + No selection Ingen markering + Rasterize Unsupported Art Rasterisér ikke-understøttet grafik + Unnamed Objects Unavngivne objekter + Width and Height Bredde og højde + Add New Tilføj ny + Delete Point Slet punkt + Location: Placering: + Spread: Spredning: + Clip to Bounding Box Klip til afgrænsningsramme + Effective PPI Effektiv PPI + Embed Image(s) Indlejr billede(r) + Embedded Image Indlejret billede + Large Thumbnails Store miniaturer + Linked Linket + Location Placering + No images Ingen billeder + Relink to Folder… Genopret link til mappe… + Show Missing Vis manglende + Sort by Status Sortér efter status + Clear Overrides Ryd tilsidesættelser + Create New {kind} Style Opret ny {kind}-typografi + Delete {kind} Style Slet {kind}-typografi + Duplicate {kind} Style Duplikér {kind}-typografi + New {kind} Style… Ny {kind}-typografi… + Redefine {kind} Style Omdefinér {kind}-typografi + {kind} Style Options… Indstillinger for {kind}-typografi… + Angle: Vinkel: + Ellipse Properties: Ellipseegenskaber: + Invert Pie Invertér udsnit + Side Length: Sidelængde: + Make Sides Equal Gør siderne lige lange + Pie End Angle: Slutvinkel for udsnit: + Pie Start Angle: Startvinkel for udsnit: + Corner Radius: Hjørneradius: + Radius: Radius: + Round Rund + Flip Horizontal Spejl vandret + Flip Vertical Spejl lodret + Polygon Properties: Polygonegenskaber: + Rectangle Properties: Rektangelegenskaber: + Shape Properties: Figuregenskaber: + Sides: Sider: + Transform options Transformeringsindstillinger + Transform Both Transformer begge + Transform Object Only Transformer kun objektet + Transform Pattern Only Transformer kun mønsteret + Use Registration Point for Symbol Brug registreringspunkt for symbol + Click to edit the mask; Alt-click to view only the mask; Shift-click to disable or enable it Klik for at redigere masken; Alt-klik for kun at se masken; Skift-klik for at deaktivere eller aktivere den + Hide Thumbnails Skjul miniaturer + Invert Mask Invertér maske + Isolate Blending Isolér blanding + Knockout Group Knockout-gruppe + Link the mask Sammenkæd masken + Make Mask Opret maske + Opacity & Mask Define Knockout Shape Opacitet og maske definerer knockout-form + Select the art (and, on top of it, the mask object) Markér grafikken (og oven på den maskeobjektet) + Show Thumbnails Vis miniaturer + Stop editing the opacity mask Stop redigering af opacitetsmasken + Unlink the mask Fjern sammenkædning af masken + Data Sets Datasæt + Data Set Datasæt + Select Bound Object Markér bundet objekt + Select All Bound Objects Markér alle bundne objekter + Multiple objects Flere objekter + Graphics: {adapter} Grafik: {adapter} + Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft. Del af ArtCraft. MIT OR Apache-2.0. Skrifttyper: Source Sans 3, Inter, JetBrains Mono (OFL). Ikoner: Lucide (ISC) + VectorCraft. + Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono, Noto Sans SC (OFL). Icons: Lucide (ISC) + VectorCraft. Del af ArtCraft. MIT OR Apache-2.0. Skrifttyper: Source Sans 3, Inter, JetBrains Mono, Noto Sans SC (OFL). Ikoner: Lucide (ISC) + VectorCraft. + Version {version} — open-source vector illustration in pure Rust. Version {version} — open source-vektorillustration i ren Rust. + Align to Page Justér til side + Smooth Color Jævn farve + Specified Distance Angivet afstand + Specified Steps Angivet antal trin + Direction: Retning: + Scatter Brush Options Indstillinger for spredningspensel + Art Brush Options Indstillinger for grafikpensel + Pattern Brush Options Indstillinger for mønsterpensel + Scatter: Spredning: + Rotation: Rotation: + Rotation relative to: Rotation i forhold til: + Brush Scale Options Indstillinger for penselskalering + Scale Proportionately Skalér proportionalt + Stretch to Fit Stroke Length Stræk til konturens længde + Stretch Between Guides Stræk mellem hjælpelinjer + Start: Start: + End: Slut: + Left to Right Venstre til højre + Right to Left Højre til venstre + Top to Bottom Top til bund + Bottom to Top Bund til top + Outer Corner Tile Ydre hjørneflise + Side Tile Sideflise + Inner Corner Tile Indre hjørneflise + Start Tile Startflise + End Tile Slutflise + Fit Tilpas + Stretch to fit Stræk for at tilpasse + Add space to fit Tilføj mellemrum for at tilpasse + Approximate path Tilnærmet sti + Colorization Farvelægning + Tints Nuancer + Tints and Shades Nuancer og skygger + Hue Shift Farvetoneskift + Key Color: Nøglefarve: + Size: Størrelse: + Calligraphic Brush Options Indstillinger for kalligrafipensel + Bristle Brush Options Indstillinger for børstepensel + Roundness: Rundhed: + Shape: Form: + Bristle Length: Børstelængde: + Bristle Density: Børstetæthed: + Bristle Thickness: Børstetykkelse: + Paint Opacity: Malingsopacitet: + Stiffness: Stivhed: + Round Point Rund spids + Flat Point Flad spids + Round Blunt Rund stump + Flat Blunt Flad stump + Round Curve Rund kurve + Flat Curve Flad kurve + Round Angle Rund vinkel + Flat Angle Flad vinkel + Round Fan Rund vifte + Flat Fan Flad vifte + Adjust Options: Justér indstillinger: + Color Mode: Farvetilstand: + Convert Konvertér + Global Global + Green Grøn + Shifts the tints of global and spot colors; other colors stay. Flytter nuancerne af globale farver og spotfarver; andre farver forbliver uændrede. + Tint Nuance + Color Guide Options Indstillinger for farveguide + Less Mindre + Steps: Trin: + Variation: Variation: + Variation Variation + Color Models Farvemodeller + Color Picker Farvevælger + Color Swatches Farveprøver + Only Web Colors Kun webfarver + Original: click to restore Original: klik for at gendanne + Ascender Overhøjde + Descender Underhøjde + On Left Side På venstre side + On Right Side På højre side + On Both Sides På begge sider + Short Kort + Full Width Fuld bredde + Chamfer Skrå + Inverted Round Omvendt rund + Bleed: Beskæring: + Discard White Overprint in Output Kassér hvidt overtryk i output + Double Quotes: Dobbelte anførselstegn: + Export and Clipboard Transparency Flattener Settings Indstillinger for udfladning af gennemsigtighed ved eksport og udklipsholder + Export: Eksport: + Grid Colors: Gitterfarver: + Grid Size: Gitterstørrelse: + Highlight Substituted Fonts Fremhæv udskiftede skrifttyper + Highlight Substituted Glyphs Fremhæv udskiftede glyffer + Language: Sprog: + Position Placering + Preserve Text Appearance Bevar tekstens udseende + Preserve Text Editability Bevar tekstens redigerbarhed + Show Images in Outline Mode Vis billeder i omridstilstand + Simulate Colored Paper Simulér farvet papir + Single Quotes: Enkelte anførselstegn: + Small Caps: Små versaler: + Subscript: Sænket skrift: + Superscript: Hævet skrift: + Transparency Grid Gennemsigtighedsgitter + Units: Enheder: + Use Typographer's Quotes Brug typografiske anførselstegn + Center Artwork Centrér grafik + DXF Import Options DXF-importindstillinger + Layout: Layout: + Merge Layers Flet lag + Place DXF Placér DXF + Scale Lineweights Skalér linjevægte + Scale to Fit Artboard Skalér til tegnebrættet + Scaling Skalering + Alter Paths for Appearance Ændr stier for udseendet + DXF Options DXF-indstillinger + Export Option Eksportindstilling + Export Selected Art Only Eksportér kun markeret grafik + Number of Colors: Antal farver: + Raster File Format: Rasterfilformat: + True colour needs version 2004 or later: the nearest of 256 colours is written. Ægte farve kræver version 2004 eller senere: den nærmeste af 256 farver skrives. + Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Tekst bliver til omrids, og konturer, som en CAD-linje ikke kan tegne, bliver til udfyldte figurer. + Type stays text, and every stroke is a line with its lineweight and dashes. Tekst forbliver tekst, og hver kontur er en linje med sin linjevægt og streger. + Version: Version: + Spin Rotation + Good God + Best Bedst + Amount Mængde + Contrast Kontrast + Edit the applied effect, or add another one? Redigér den anvendte effekt, eller tilføj endnu en? + Input White Hvidt input + Output Black Sort output + Preserve Lightness Bevar lyshed + Ridges per segment: Rygge pr. segment: + Temperature Temperatur + {effect} is already applied {effect} er allerede anvendt + Odd Fields Ulige felter + Even Fields Lige felter + Duplication Duplikering + Interpolation Interpolation + Anti-Alias Antialias + Bend: Bøjning: + Distort Appearance Forvræng udseende + Distort Linear Gradients Forvræng lineære gradienter + Distort Pattern Fills Forvræng mønsterudfyldninger + Distortion Forvrængning + Envelope Mesh Konvolutnet + Maintain Envelope Shape Bevar konvolutform + Preserve Shape Using: Bevar form ved hjælp af: + Reset Envelope Mesh Nulstil konvolutnet + Reset Envelope Shape Nulstil konvolutform + V: V: + Warp Options Vridningsindstillinger + Compatible Gradient and Gradient Mesh Printing Kompatibel udskrivning af gradienter og gradientnet + Embed Fonts (for other applications) Indlejr skrifttyper (til andre programmer) + EPS Options EPS-indstillinger + Include CMYK PostScript in RGB Files Medtag CMYK-PostScript i RGB-filer + Include Document Thumbnails Medtag dokumentminiaturer + Include Linked Files Medtag linkede filer + Opaque Uigennemsigtig + PostScript: PostScript: + Transparency is flattened with the preset. Type is written as outlines; gradients as bands of colour. Gennemsigtigheden udflades med forudindstillingen. Tekst skrives som omrids; gradienter som farvebånd. + Transparency is flattened with the preset. Type is written as outlines; gradients stay smooth. Gennemsigtigheden udflades med forudindstillingen. Tekst skrives som omrids; gradienter forbliver jævne. + Expand Gradient To Udvid gradient til + Gradient Mesh Gradientnet + Specify: Angiv: + Export As Eksportér som + One file per artboard: -.{ext} Én fil pr. tegnebræt: -.{ext} + One page per artboard. Én side pr. tegnebræt. + The bounds of the visible art. Den synlige grafiks grænser. + Use Artboards Brug tegnebrætter + Choose a folder Vælg en mappe + Create Sub-folders Opret undermapper + Export Asset Eksportér aktiv + Export to: Eksportér til: + Format Settings Formatindstillinger + Formats: Formater: + Full Document Hele dokumentet + Include Bleed Medtag beskæring + No assets yet. Collect art with Object › Collect for Export, or drag it into the Asset Export panel. Ingen aktiver endnu. Saml grafik med Objekt › Saml til eksport, eller træk den ind i panelet Aktiv-eksport. + Open Location after Export Åbn placering efter eksport + PDF Preset: PDF-forudindstilling: + Prefix: Præfiks: + Remove Fjern + Select: Markér: + Your browser downloads the files (several as one .zip). Din browser downloader filerne (flere som én .zip). + Cap Afslutning + Dash Pattern Stregmønster + Eyedropper Applies: Pipetten anvender: + Eyedropper Picks Up: Pipetten henter: + Focal Fill Fokal udfyldning + Focal Stroke Fokal kontur + Miter Limit Spidsgrænse + Point Sample Punktsampling + Raster Sample Size: Rasterprøvestørrelse: + Weight Vægt + 3 x 3 Average 3 x 3 gennemsnit + 5 x 5 Average 5 x 5 gennemsnit + Author Title: Forfattertitel: + Author: Forfatter: + Copyright Info URL: URL til ophavsretinfo: + Copyright Notice: Ophavsretserklæring: + Copyright Status: Ophavsretstatus: + Created: Oprettet: + Dates Datoer + Description: Beskrivelse: + Document Title: Dokumenttitel: + Keywords: Nøgleord: + Modified: Ændret: + Rating: Bedømmelse: + Remove “{keyword}” Fjern »{keyword}« + Separate several keywords with commas. Adskil flere nøgleord med kommaer. + Flare Tool Options Indstillinger for Flare-værktøjet + Rings Ringe + Brightness: Lysstyrke: + Growth: Vækst: + Fuzziness: Uklarhed: + Number: Antal: + Longest: Længste: + Path: Sti: + Largest: Største: + Anti-alias Rasters Antialias rasterbilleder + Clip Complex Regions Klip komplekse områder + Convert All Strokes to Outlines Konvertér alle konturer til omrids + Convert All Text to Outlines Konvertér al tekst til omrids + Gradient and Mesh Resolution: Opløsning for gradienter og net: + Line Art and Text Resolution: Opløsning for streggrafik og tekst: + Preserve Alpha Transparency Bevar alfa-gennemsigtighed + Preserve Overprints and Spot Colors Bevar overtryk og spotfarver + Rasters Rasterbilleder + Replaces the saved preset of that name. Erstatter den gemte forudindstilling med det navn. + Save Preset… Gem forudindstilling… + Vectors Vektorer + A saved copy of the selected preset En gemt kopi af den valgte forudindstilling + Built-in presets don't change: New makes a copy you can edit. Indbyggede forudindstillinger ændres ikke: Ny opretter en kopi, du kan redigere. + Horizontal % Vandret % + Include Cmy Blacks Medtag Cmy-sort + Make all bleed settings the same Gør alle beskæringsindstillinger ens + Make the bleed values differ Gør beskæringsværdierne forskellige + Miter Limit: Spidsgrænse: + Radius 1 Radius 1 + Radius 2 Radius 2 + Radius1 Radius1 + Radius2 Radius2 + Relative Relativ + Absolute Absolut + Vertical % Lodret % + Miter Spids + Bevel Facet + A lower value follows the stroke closely; a higher one gives fewer points and smoother curves. En lavere værdi følger konturen tæt; en højere giver færre punkter og jævnere kurver. + Accurate Nøjagtig + Close paths when ends are within: Luk stier, når enderne er inden for: + Edit selected paths within: Redigér markerede stier inden for: + Fidelity: Præcision: + Fill new strokes Udfyld nye konturer + The document has no colour swatches. Dokumentet har ingen farveprøver. + Style Name: Typografinavn: + Crop To Beskær til + e.g. 1-3, 5 f.eks. 1-3, 5 + First Page Første side + Import PDF Importér PDF + Last Page Sidste side + Next Page Næste side + of {count} af {count} + of af + Previous Page Forrige side + “{file}” is protected by a password. »{file}« er beskyttet med en adgangskode. + Dim Images to: Nedton billeder til: + Light Blue Lyseblå + Layers Panel Options Indstillinger for lagpanelet + Row Size: Rækkestørrelse: + Show Layers Only Vis kun lag + Thumbnails: Miniaturer: + Anchor Points Ankerpunkter + Brush Affects: Penslen påvirker: + Complexity: Kompleksitet: + Detail: Detaljer: + Global Brush Dimensions Globale penseldimensioner + Horizontal: Vandret: + In Tangent Handles Indgående tangenthåndtag + Out Tangent Handles Udgående tangenthåndtag + Show Brush Size Vis penselstørrelse + Simplify: Simplificering: + Twirl Rate: Rotationshastighed: + Use Pressure Pen Brug trykpen + Vertical: Lodret: + {shortcut} with the tool sizes the brush {shortcut} med værktøjet ændrer penselens størrelse + {tool} Options Indstillinger for {tool} + {tool} Tool Options Indstillinger for {tool}-værktøjet + These fonts aren't available. Their text shows in another font. Disse skrifttyper er ikke tilgængelige. Deres tekst vises i en anden skrifttype. + Search Fonts Folder Søg i skrifttypemappe + Add Fonts Tilføj skrifttyper + Find Fonts Find skrifttyper + Stop Stop + Searching… Søger… + Not found Ikke fundet + {folders} folder(s) and {files} file(s) searched {folders} mappe(r) og {files} fil(er) gennemsøgt + {count} folder(s) skipped, such as other apps' and the system's folders and hidden ones {count} mappe(r) sprunget over, f.eks. andre apps' og systemets mapper samt skjulte mapper + {count} folder(s) or file(s) couldn't be read {count} mappe(r) eller fil(er) kunne ikke læses + The search stopped at its limit: search a smaller folder. Søgningen stoppede ved sin grænse: søg i en mindre mappe. + Copy only fonts you own or are licensed to install. Kopiér kun skrifttyper, du ejer eller har licens til at installere. + VectorCraft copies the fonts to {folder}. VectorCraft kopierer skrifttyperne til {folder}. + Apply to All ({count} more missing) Anvend på alle ({count} flere mangler) + Ignore Ignorér + Linked File Not Found Linket fil blev ikke fundet + Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Erstat den med en anden fil, eller ignorér den: dens billeder viser stadig den forhåndsvisning, der er gemt sammen med dokumentet. + Replace… Erstat… + Update Modified Links Opdatér ændrede links + {count} linked image(s) changed since this document was saved. Show the new versions? {count} linkede billede(r) er ændret, siden dette dokument blev gemt. Vis de nye versioner? + Dialog Dialog + Convert Process to Global Konvertér procesfarve til global + Create From: Opret ud fra: + Include Swatches for Tints Medtag farveprøver til nuancer + Selected Artwork Markeret grafik + Selected Swatches Markerede farveprøver + Advanced Options Avancerede indstillinger + Background Contents Baggrundens indhold + Background Contents: Baggrundens indhold: + BLANK DOCUMENT PRESETS TOMME DOKUMENTFORUDINDSTILLINGER + Bleed Beskæring + Change to Right-to-Left Layout Skift til højre-til-venstre-layout + Columns: Kolonner: + Create Document Opret dokument + Documents you create appear here. Dokumenter, du opretter, vises her. + More Settings Flere indstillinger + New from Template Nyt fra skabelon + Number of Artboards: Antal tegnebrætter: + Orientation: Orientering: + PRESET DETAILS FORUDINDSTILLINGSDETALJER + Preset Name Forudindstillingsnavn + Preset {} Forudindstilling {} + Presets you save with the Save Preset button appear here. Forudindstillinger, du gemmer med knappen Gem forudindstilling, vises her. + Preview Mode Forhåndsvisningstilstand + RECENT SENESTE + RECENT (1) SENESTE (1) + SAVED GEMT + Templates… Skabeloner… + Transparent (Default) Gennemsigtig (standard) + Save for Office Documents Gem til Office-dokumenter + Transparent Background Gennemsigtig baggrund + Choose… Vælg… + Copy Fonts Used in the Document Kopiér skrifttyper, der bruges i dokumentet + Copy Links Kopiér links + Create Report Opret rapport + Fonts whose licence doesn't allow embedding are not copied. Skrifttyper, hvis licens ikke tillader indlejring, kopieres ikke. + Package collects a saved document with its linked files and fonts. Pakning samler et gemt dokument med dets linkede filer og skrifttyper. + Package created Pakke oprettet + Packaged {n} file(s) in {folder}{note} Pakkede {n} fil(er) i {folder}{note} + Packaged {n} file(s){note} Pakkede {n} fil(er){note} + Relink Linked Files to the Document Genopret link til dokumentets linkede filer + Save the document first? Gem dokumentet først? + {count} file(s) in {folder}{note}. Show the package folder? {count} fil(er) i {folder}{note}. Vis pakkemappen? + {count} linked file(s) not found {count} linkede fil(er) ikke fundet + fonts not copied: {fonts} skrifttyper ikke kopieret: {fonts} + Built-in presets are read-only: New… starts an editable copy. Indbyggede forudindstillinger er skrivebeskyttede: Ny… starter en redigerbar kopi. + 1 {unit} on the artboard stands for {real} {unit} in the scene. 1 {unit} på tegnebrættet svarer til {real} {unit} i scenen. + Grid Color & Opacity Gitterfarve og opacitet + Gridline every: Gitterlinje hver: + Horizon Height: Horisonthøjde: + Horizontal Grid: Vandret gitter: + Left Grid: Venstre gitter: + Perspective Grid Settings Indstillinger for perspektivgitter + Real World: Virkelig verden: + Right Grid: Højre gitter: + Save Grid as Preset Gem gitter som forudindstilling + Save these settings as a preset Gem disse indstillinger som en forudindstilling + Third Vanishing Point: Tredje forsvindingspunkt: + Viewing Angle: Synsvinkel: + Viewing Distance: Synsafstand: + Show Active Plane Widget Vis widget for aktivt plan + Widget Position: Widgetplacering: + Copy All Objects Kopiér alle objekter + Do Not Move Flyt ikke + Horizontal Plane Vandret plan + Left Plane Venstre plan + Move All Objects Flyt alle objekter + Right Plane Højre plan + Built-in presets don't change: New… starts an editable copy. Indbyggede forudindstillinger ændres ikke: Ny… starter en redigerbar kopi. + One Point Ét punkt + Perspective grid presets Forudindstillinger for perspektivgitter + Three Point Tre punkter + Two Point To punkter + User Preset Brugerforudindstilling + Click to place each file at 100%, or drag to size it; arrow keys switch files, Esc skips one. Klik for at placere hver fil med 100%, eller træk for at ændre dens størrelse; piletasterne skifter fil, og Esc springer en over. + Keep a link to an image file instead of only embedding it Behold et link til en billedfil i stedet for kun at indlejre den + Place onto a new locked, dimmed template layer below the current layer Placér på et nyt låst, nedtonet skabelonlag under det aktuelle lag + Swap the selected object for the file, keeping its place and transform Udskift det markerede objekt med filen, mens placering og transformering bevares + On: one locked object showing the document, updated when it changes. Til: ét låst objekt, der viser dokumentet, og som opdateres, når det ændres. + Off: an editable copy of its art, embedded in this document. Fra: en redigerbar kopi af dens grafik, indlejret i dette dokument. + Images: link to the file instead of only embedding it. Billeder: link til filen i stedet for kun at indlejre den. + VectorCraft documents: one locked object, updated when the file changes. VectorCraft-dokumenter: ét låst objekt, som opdateres, når filen ændres. + Alignment: Justering: + Applies when the linked file is relinked or updated. Anvendes, når linket til filen genoprettes eller filen opdateres. + Bounds (Fill Proportionally) Grænser (udfyld proportionalt) + Bounds (Fit Proportionally) Grænser (tilpas proportionalt) + File Dimensions Fildimensioner + Transforms Transformationer + Background Color: Baggrundsfarve: + Client-side (.html) Klientbaseret (.html) + Color Reduction: Farvereduktion: + Lossless (lossy WebP isn't available yet) Tabsfri (WebP med tab er ikke tilgængelig endnu) + Scans: Scanninger: + Server-side (.map) Serverbaseret (.map) + {format} Options Indstillinger for {format} + 0.2 (quality) to 100 (speed) 0,2 (kvalitet) til 100 (hastighed) + All the art Al grafikken + Artboard {number} Tegnebræt {number} + Auto-Rotate (turn the paper to each artboard) Auto-rotér (drej papiret til hvert tegnebræt) + Automatic flatness Automatisk fladhed + Can't print these settings: see the Summary Disse indstillinger kan ikke udskrives: se oversigten + CMYK colours print with their own values; off, they go through the colour settings too. CMYK-farver udskrives med deres egne værdier; slået fra går de også gennem farveindstillingerne. + Collate Sortér + Composite colours are converted to it; separations separate with it when it is a CMYK profile. Kompositfarver konverteres til den; separationer separeres med den, når det er en CMYK-profil. + Convert All Spot Colors to Process Konvertér alle spotfarver til procesfarver + Default Printer Standardprinter + Document Ink Options Indstillinger for dokumentblæk + Download: Download: + Drag the art to move it on the paper Træk i grafikken for at flytte den på papiret + Each page prints as one image, at the document's raster effects resolution. Hver side udskrives som ét billede ved dokumentets opløsning for rastereffekter. + Edit Print Preset Redigér udskriftsforudindstilling + Emulsion: Emulsion: + Every option is at its default. Alle indstillinger er sat til standard. + Flatness: Fladhed: + Frequencies and angles are the output device's to apply. Frekvenser og vinkler påføres af outputenheden. + General › Placement › X Generelt › Placering › X + How colours outside the press's gamut are separated. Hvordan farver uden for trykkeriets farveomfang separeres. + Ignore Artboards (all the art as one page) Ignorér tegnebrætter (al grafikken som én side) + Image: Billede: + Marks and Bleed Mærker og beskæring + Media Medie + New Print Preset Ny udskriftsforudindstilling + Next page Næste side + No printer settings to open here Ingen printerindstillinger at åbne her + None (keep transparency) Ingen (bevar gennemsigtighed) + Nothing to print Intet at udskrive + Open a document to preview its pages Åbn et dokument for at forhåndsvise dets sider + Output › Mode Output › Tilstand + Overprint and Transparency Flattener Options Indstillinger for overtryk og udfladning af gennemsigtighed + Place the pages with the placement again Placér siderne igen med placeringen + Placement: Placering: + Preserve CMYK Numbers Bevar CMYK-tal + Previous page Forrige side + Print Layers: Udskriv lag: + Print Preset: Udskriftsforudindstilling: + Printer Printer + Printer profile: Printerprofil: + Printer: Printer: + Rendering intent: Gengivelseshensigt: + Same As Source Samme som kilde + Save these settings as a print preset Gem disse indstillinger som en udskriftsforudindstilling + Scaling: Skalering: + Separations list the document's inks here: one page per ink that prints. Separationer viser dokumentets blækfarver her: én side pr. blækfarve, der udskrives. + Setup… Opsætning… + Skip Blank Artboards Spring tomme tegnebrætter over + The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. Papirkanten, som printeren ikke kan nå: grafikken placeres inden for den, og forhåndsvisningen viser den stiplet. + The page as it prints Siden, som den udskrives + The Print Tiling tool placed the pages. Værktøjet til udskrivning i fliser har placeret siderne. + The system's settings of this printer Systemets indstillinger for denne printer + Tile Full Pages Fliselæg hele sider + Tile Range: Fliseinterval: + tile {number} flise {number} + Tiles are numbered across, then down; leave the range empty for every tile. Fliser nummereres på tværs og derefter nedad; lad intervallet stå tomt for alle fliser. + Transverse (the page a quarter turn on the paper) På tværs (siden drejes en kvart omgang på papiret) + Unprintable margin: Ikke-udskrivbar margen: + {pages} in all, {count} per copy {pages} i alt, {count} pr. kopi + {page} of {count} {page} af {count} + PDF File PDF-fil + [Default] is protected: New… starts an editable copy. [Standard] er beskyttet: Ny… starter en redigerbar kopi. + A new preset starting from the selected one En ny forudindstilling, der starter ud fra den valgte + Differences from {preset}: Afvigelser fra {preset}: + Differences from [Default]: Afvigelser fra [Standard]: + Differences from VectorCraft Default: Afvigelser fra VectorCraft-standard: + Edit… Redigér… + None. Ingen. + Presets: Forudindstillinger: + Save the selected preset to a file Gem den valgte forudindstilling i en fil + Embed ICC Profile Indlejr ICC-profil + Flat Image Fladt billede + Include Hidden Layers Medtag skjulte lag + Maximum Editability Maksimal redigerbarhed + Options: Indstillinger: + Write Layers Skriv lag + Anti-alias Antialias + Around Object Omkring objektet + Background: Baggrund: + Changing these settings changes how raster effects look when exported, flattened or expanded. Ændring af disse indstillinger ændrer, hvordan rastereffekter ser ud, når de eksporteres, udflades eller udvides. + Color Model: Farvemodel: + Create Clipping Mask Opret klipemaske + Preserve Spot Colors Bevar spotfarver + Resolution: Opløsning: + Transparent Gennemsigtig + Layout Order: Layoutrækkefølge: + Change to Left-to-Right Layout Skift til venstre-til-højre-layout + Artboards: {n} Tegnebrætter: {n} + Rows: Rækker: + Assign Tildel + Color Group: Farvegruppe: + Color Library Farvebibliotek + Colors: Farver: + Current Colors ({total}) Aktuelle farver ({total}) + Current Colors Aktuelle farver + Current Colors (0) Aktuelle farver (0) + Harmony Rules: Harmoniregler: + Harmony Rules Harmoniregler + Keep this row's colors (exclude) Behold farverne i denne række (ekskludér) + Limit to Library: Begræns til bibliotek: + Link harmony colors Sammenkæd harmonifarver + Merge colors into one row Flet farver til én række + No colors to recolor (preserved colors are left out). Ingen farver at omfarve (bevarede farver er udeladt). + Preserve: Bevar: + Randomly change color order Ændr farverækkefølgen tilfældigt + Randomly change saturation and brightness Ændr mætning og lysstyrke tilfældigt + Recolor this row Omfarv denne række + Reset the rows to the artwork's colors Nulstil rækkerne til grafikkens farver + Separate colors into rows Opdel farver i rækker + Unlink harmony colors Fjern sammenkædning af harmonifarver + 1 Color Job 1-farvejob + 2 Color Job 2-farvejob + 3 Color Job 3-farvejob + Recover Documents Gendan dokumenter + Restored documents open unsaved: save them to keep them. Gendannede dokumenter åbnes uden at være gemt: gem dem for at beholde dem. + VectorCraft didn't quit normally last time. These documents had unsaved changes: VectorCraft blev ikke lukket normalt sidst. Disse dokumenter havde ugemte ændringer: + Do you want to save the changes you made to “{name}”? Vil du gemme de ændringer, du har foretaget i »{name}«? + Don't Save Gem ikke + Your changes will be lost if you don't save them. Dine ændringer går tabt, hvis du ikke gemmer dem. + < 1 sec < 1 sek + [Unnamed] [Unavngiven] + Amount: Mængde: + Anti-aliasing: Antialiasering: + Artboard: Tegnebræt: + Color Table Farvetabel + Color Table (4 colors) Farvetabel (4 farver) + Color Table ({n} color) Farvetabel ({n} farve) + Color Table ({n} colors) Farvetabel ({n} farver) + Convert to sRGB Konvertér til sRGB + Delete this saved preset Slet denne gemte forudindstilling + Dither: Dither: + Downloaded {} file(s) Downloadede {} fil(er) + Fit in View Tilpas i visning + Format: Format: + WebP WebP + High Høj + ICC Profile ICC-profil + Image Size Billedstørrelse + Interlaced Interlaceret + Lossy: Med tab: + Low Lav + Matte: Mat: + Maximum Maksimum + Metadata: Metadata: + Needs the desktop app Kræver skrivebordsappen + Optimized Optimeret + Order: Rækkefølge: + Original Original + 2-Up 2-Up + 4-Up 4-Up + Output: Output: + Percent: Procent: + Preview in Browser needs the desktop app (the web downloads files instead) Forhåndsvisning i browser kræver skrivebordsappen (webversionen downloader filer i stedet) + Progressive Progressiv + Quality {quality} Kvalitet {quality} + Quality: Kvalitet: + Reduction: Reduktion: + Save the settings as a preset Gem indstillingerne som en forudindstilling + Saved {n} files beside {path} Gemte {n} filer ved siden af {path} + Shown with the optimised preview. Vises med den optimerede forhåndsvisning. + Slices: Udsnit: + Unlock Lås op + Unshift Fjern skift + Very High Meget høj + Web Shift Web-skift + Web Snap: Fastgør til webfarver: + {secs} sec {secs} sek + Create PDF-Compatible File Opret PDF-kompatibel fil + Embed ICC Profiles Indlejr ICC-profiler + File Name: Filnavn: + Save As Gem som + Save as Template Gem som skabelon + Save each artboard to a separate file Gem hvert tegnebræt i en separat fil + All printer's marks Alle trykmærker + Bleeds Beskæringer + Bottom: Bund: + Changes allowed: Tilladte ændringer: + Color bars Farvebjælker + Color conversion: Farvekonvertering: + Color images Farvebilleder + Compatibility: Kompatibilitet: + PDF 1.7 PDF 1.7 + VectorCraft Default VectorCraft-standard + High Quality Print Udskrivning i høj kvalitet + [High Quality Print] [Udskrivning i høj kvalitet] + Press Quality Trykkvalitet + [Press Quality] [Trykkvalitet] + Smallest File Size Mindste filstørrelse + [Smallest File Size] [Mindste filstørrelse] + PDF/X-1a:2001 PDF/X-1a:2001 + [PDF/X-1a:2001] [PDF/X-1a:2001] + PDF/X-3:2002 PDF/X-3:2002 + [PDF/X-3:2002] [PDF/X-3:2002] + PDF/X-4:2010 PDF/X-4:2010 + [PDF/X-4:2010] [PDF/X-4:2010] + Keeps the document editable: VectorCraft reopens the PDF with nothing lost. For files you'll edit again. Bevarer dokumentet redigerbart: VectorCraft genåbner PDF'en uden at miste noget. Til filer, du vil redigere igen. + Compress text and line art Komprimér tekst og streggrafik + Compression Komprimering + Compression › Color › Above Ppi Komprimering › Farve › Over Ppi + Compression: Komprimering: + Condition identifier: Betingelses-id: + Convert text to outlines Konvertér tekst til omrids + Create PDF layers from layers and sublayers Opret PDF-lag ud fra lag og underlag + Description Beskrivelse + Destination: Destination: + Document open password Adgangskode til at åbne dokumentet + Document profile Dokumentprofil + Edit PDF Preset Redigér PDF-forudindstilling + Embed page thumbnails Indlejr miniaturebilleder af sider + Enable copying of text, images and other content Tillad kopiering af tekst, billeder og andet indhold + Enable plaintext metadata Tillad metadata i klartekst + Enable text access for screen readers Tillad tekstadgang for skærmlæsere + Encryption: {name} (set by Compatibility) Kryptering: {name} (angivet af kompatibilitet) + Error Fejl + Every option matches {base}. Alle indstillinger matcher {base}. + Files of a PDF standard can't be password-protected. Filer med en PDF-standard kan ikke beskyttes med adgangskode. + Files of this standard tag their colours with ICC profiles. Filer med denne standard mærker deres farver med ICC-profiler. + Flattener preset: Forudindstilling til fladgøring: + General › Thumbnails Generelt › Miniaturer + Grayscale images Gråtonebilleder + Include non-printing layers Medtag lag, der ikke udskrives + Left: Venstre: + Mark as trapped Markér som trappet + Marks and Bleeds Mærker og udløb + Monochrome images Monokrome billeder + Monochrome Monokrom + New PDF Preset Ny PDF-forudindstilling + of characters used af de anvendte tegn + Offset: Forskydning: + On Slået til + Open password: Adgangskode til at åbne: + Optimize for fast web view Optimér til hurtig webvisning + Output condition: Outputbetingelse: + Output Intent Outputintent + Output intent profile: Profil for outputintent: + Overprint and Transparency Flattener Overprint og fladgøring af gennemsigtighed + Overprint: Overprint: + Page information Sideinformation + PDF 1.3 files have no transparency: the preset flattens it. PDF 1.3-filer har ingen gennemsigtighed: forudindstillingen fladgør den. + PDF/A files carry their own output intent. PDF/A-filer har deres egen outputintent. + PDF/X-1a files are CMYK: every colour is converted to the destination (blank: the output intent's CMYK profile), untagged. PDF/X-1a-filer er CMYK: alle farver konverteres til destinationen (tom: outputintentens CMYK-profil), umærkede. + Permissions Rettigheder + Permissions password: Rettighedsadgangskode: + Preserve editing capabilities Bevar redigeringsfunktioner + Preset name: Navn på forudindstilling: + Printer mark type: Type af printermærke: + Printing allowed: Udskrivning tilladt: + Profile inclusion: Inkludering af profil: + Range: Interval: + Registration marks Registreringsmærker + Registry name: Registreringsnavn: + Require a password to open the document Kræv en adgangskode for at åbne dokumentet + Restrict printing, editing and other tasks Begræns udskrivning, redigering og andre opgaver + Right: Højre: + Save as preset: Gem som forudindstilling: + Save PDF Gem PDF + Security Sikkerhed + Standard: Standard: + Subset fonts below: Delmængde af skrifttyper under: + the preset forudindstillingen + Top: Øverst: + Trim mark weight: Vægt for beskæringsmærker: + Trim marks Beskæringsmærker + Use document bleed settings Brug dokumentets udløbsindstillinger + View PDF after saving Vis PDF efter lagring + Warnings Advarsler + CMYK profile in effect CMYK-profil i brug + Save Graphic Style Library Gem grafikstilbibliotek + Selected Styles Only Kun valgte stile + Selected Styles Only (0) Kun valgte stile (0) + A File… En fil… + GPL palettes keep solid colours only, as RGB. GPL-paletter indeholder kun massive farver, som RGB. + Save Swatch Library Gem farveprøvebibliotek + VectorCraft Swatches (.vcswatches) VectorCraft-farveprøver (.vcswatches) + Save To: Gem i: + Saved to User Defined: {} Gemt i Brugerdefineret: {} + Selected Swatches Only Kun valgte farveprøver + Selected Swatches Only (0) Kun valgte farveprøver (0) + User Defined Libraries Brugerdefinerede biblioteker + Copy Report Kopiér rapport + VectorCraft read this script without running it. VectorCraft læste dette script uden at køre det. + Needs nothing beyond plain JavaScript Kræver intet ud over ren JavaScript + Needs the script language's own objects or directives Kræver scriptsprogets egne objekter eller direktiver + Needs the document model Kræver dokumentmodellen + Needs files Kræver filer + Needs menu commands Kræver menukommandoer + Needs dialogs Kræver dialogbokse + Needs features only the reference app has Kræver funktioner, som kun referenceappen har + Outside the document Uden for dokumentet + Writes, moves or deletes files Skriver, flytter eller sletter filer + Opens files in other apps Åbner filer i andre apps + Opens network connections Åbner netværksforbindelser + Loads native code Indlæser nativ kode + Sends messages to other apps Sender meddelelser til andre apps + Reads or sets environment variables Læser eller angiver miljøvariabler + Runs code the checker can't read Kører kode, som kontrollen ikke kan læse + Changes the reference app's preferences Ændrer referenceappens indstillinger + Menu commands Menukommandoer + Names the script builds as it runs: {count} Navne, som scriptet opretter, mens det kører: {count} + Document model Dokumentmodel + Object types: {names} Objekttyper: {names} + Directives Direktiver + Includes Inkluderinger + Checked Kontrolleret + Not followed: outside the script's folder Ikke fulgt: uden for scriptets mappe + Includes itself Inkluderer sig selv + Not read Ikke læst + Not read: a limit was reached Ikke læst: en grænse blev nået + Compiled or not text Kompileret eller ikke tekst + Problems Problemer + String not closed Streng ikke lukket + Comment not closed Kommentar ikke lukket + Regular expression not closed Regulært udtryk ikke lukket + Template literal not closed Skabelonliteral ikke lukket + XML literal not closed XML-literal ikke lukket + Bracket never closed Klamme aldrig lukket + Closing bracket with no opening one Lukkende klamme uden åbnende klamme + Syntax newer than ES3 Syntaks nyere end ES3 + Unknown directive Ukendt direktiv + Not valid UTF-8: unreadable bytes were replaced Ikke gyldig UTF-8: ulæselige bytes blev erstattet + Mixed line endings Blandede linjeafslutninger + Nesting too deep to follow For dyb indlejring til at følge + line {line} linje {line} + lines {lines} linjer {lines} + {file}, line {line} {file}, linje {line} + Compiled script: ask its author for the .jsx file Kompileret script: bed forfatteren om .jsx-filen + Too large to check For stor til at kontrollere + Not a script for the reference app Ikke et script til referenceappen + Some lists were cut short at their limit. Nogle lister blev afkortet ved deres grænse. + Line Segment Tool Options Indstillinger for linjesegmentværktøj + Alt: Alternativ: + Divide Horizontally Into Del vandret i + Divide Vertically Into Del lodret i + Matte Mat + Message: Meddelelse: + N slices …, evenly spaced N udsnit …, jævnt fordelt + per slice pr. udsnit + Slice Type: Udsnitstype: + slices across, evenly spaced udsnit henover, jævnt fordelt + slices down, evenly spaced udsnit nedad, jævnt fordelt + Target: Mål: + Text Displayed in Cell: Tekst vist i celle: + The cell shows the type object's text as HTML. Cellen viser tekstobjektets tekst som HTML. + Lab Spot Colors: Lab-spotfarver: + Lab Spot Colors Lab-spotfarver + Lab Values Lab-værdier + Show and print from the Lab definition; PDF files carry it as the ink's alternate color. Vis og udskriv fra Lab-definitionen; PDF-filer medtager den som blækkets alternativfarve. + Show and separate through the working CMYK space, as in older documents. Vis og separer via CMYK-arbejdsrummet, som i ældre dokumenter. + CMYK Equivalents CMYK-ækvivalenter + Convert To Outlines Konvertér til omrids + Decimal: Decimal: + Embed Fonts (Glyphs Used) Indlejr skrifttyper (anvendte glyffer) + Fewer Elements Færre -elementer + Font: Skrifttype: + Hide Code Skjul kode + Images: Billeder: + Include Metadata Medtag metadata + Internal CSS Intern CSS + Layer Names Lagnavne + Minify Minificér + Minimal Minimal + Object IDs: Objekt-id'er: + Presentation Attributes Præsentationsattributter + Preserve Editing Capabilities Bevar redigeringsfunktioner + Responsive Responsiv + Show Code Vis kode + Style Attributes Stilattributter + Style Attributes (Entity References) Stilattributter (enhedsreferencer) + Styling: Styling: + SVG Profile: SVG-profil: + SVG 1.1 SVG 1.1 + Unicode (UTF-16) Unicode (UTF-16) + Unicode (UTF-8) Unicode (UTF-8) + Unique Unik + Conflict {n} of {count} Konflikt {n} af {count} + Keep the pasted color as a new swatch with a numbered name. Behold den indsatte farve som en ny farveprøve med et nummereret navn. + Pasted Indsat + This document already has a swatch named “{name}” with another color. Dette dokument har allerede en farveprøve med navnet »{name}«, som har en anden farve. + Use the document's swatch: pasted colors take its color. Brug dokumentets farveprøve: indsatte farver får dens farve. + Color Type: Farvetype: + Swatch Name: Farveprøvenavn: + Diameter: Diameter: + Symbolism Tools Options Indstillinger for symbolværktøjer + Symbol Set Density: Tæthed for symbolsæt: + Intensity: Intensitet: + Place PDF Placér PDF + Collect Links in a Separate Folder Saml links i en separat mappe + Folder Name: Mappenavn: + Color Management Farvestyring + Summary Oversigt + Download Download + Export Artboard Eksportér kunstplade + Encoding: Kodning: + UTF-16 (Unicode) UTF-16 (Unicode) + Line Endings: Linjeafslutninger: + LF (macOS, Linux) LF (macOS, Linux) + CRLF (Windows) CRLF (Windows) + Text Export Options Indstillinger for teksteksport + Character Set: Tegnsæt: + Join the lines of each paragraph Forbind linjerne i hvert afsnit + Line Breaks Linjeskift + Mac Mac + or more spaces with a tab eller flere mellemrum med et tabulatortegn + Platform: Platform: + Remove blank lines between paragraphs Fjern tomme linjer mellem afsnit + Spaces Mellemrum + Text Import Options Indstillinger for tekstimport + Unicode Unicode + Byte Order: Byte-rækkefølge: + Compress (RLE) Komprimér (RLE) + Depth: Dybde: + File Format: Filformat: + Flip Row Order Vend rækkefølgen + LZW Compression LZW-komprimering + Copy (make a transformed copy) Kopiér (opret en transformeret kopi) + Reference Point Referencepunkt + Reflect X Spejl X + Reflect Y Spejl Y + Kind Type + Variable Variabel + no data set to edit intet datasæt at redigere + New Name Nyt navn + no variable to rename ingen variabel at omdøbe + no data set to rename intet datasæt at omdøbe + Variable Options Indstillinger for variabel + Define a variable first Definér først en variabel + Adjust Adjoining Width Points Justér tilstødende breddepunkter + Keep both sides the same width Bevar samme bredde på begge sider + Side 1: Side 1: + Side 2: Side 2: + Total Width: Samlet bredde: + Width Point Edit Redigér breddepunkt + Art Kunst + Bristle Borst + Calligraphic Kalligrafisk + Dots Prikker + Fixed Fast + Pressure Tryk + Random Tilfældig + Scatter Spredning + Color Burn Farvebrænding + Color Dodge Farveudtynding + Darken Mørkere + Exclusion Udelukkelse + Hard Light Hårdt lys + Lighten Lysere + Luminosity Luminans + Overlay Overlejring + Soft Light Blødt lys + Crosshatch Krydsskravering + Fine Dots Fine prikker + Medium Dots Mellemstore prikker + Grainy Dots Kornede prikker + Coarse Dots Grove prikker + Short Lines Korte linjer + Medium Lines Mellemstore linjer + Long Lines Lange linjer + Short Strokes Korte strøg + Medium Strokes Mellemstore strøg + Long Strokes Lange strøg + Soft Blød + Screen Skærm +axis Horizontal Vandret +axis Vertical Lodret + Sprinkles Dryss + Clumped Klumpet + Contrasty Kontraststærk + Enlarged Forstørret + Stippled Prikket + Speckle Spættet + Brick Tegl + Burlap Sækkevæv + Canvas Lærred + Sandstone Sandsten + Bottom Left Nederst til venstre + Top Left Øverst til venstre + Top Right Øverst til højre + Bottom Right Nederst til højre +effect Soft Blød + Blocks Blokke + Frosted Frostet + Tiny Lens Lille linse + Right Diagonal Højre diagonal + Left Diagonal Venstre diagonal +effect Horizontal Vandret +effect Vertical Lodret + Diamond Diamant + Ellipse Ellipse + Add Effect Tilføj effekt + Add New Fill Tilføj ny udfyldning + Add New Stroke Tilføj ny kontur + Appearance Item Udseende-element + Clear Appearance Ryd udseende + Duplicate Item Duplikér element + Eyedropper Pipette + Move Appearance Flyt udseende + Reduce to Basic Appearance Reducér til grundlæggende udseende + Remove Item Fjern element + Reorder Appearance Item Omarrangér udseende-element + Select Appearance Item Markér udseende-element + Show All Hidden Attributes Vis alle skjulte attributter + Target Contents Målret indhold +theme Appearance Udseende + Asset Export Settings Indstillinger for eksport af aktiver + Assets Aktiver + Export Assets Eksportér aktiver + Remove Asset Fjern aktiv + Rename Asset Omdøb aktiv + Fill Rule Udfyldningsregel + Apply Brush Anvend pensel + Break Link to Symbol Bryd link til symbol + Brush Definition Penseldefinition + Brush Options… Penselindstillinger… + Current Brush Aktuel pensel + Current Symbol Aktuelt symbol + Delete Brush Slet pensel + Delete Symbol Slet symbol + Duplicate Brush Duplikér pensel + Duplicate Symbol Duplikér symbol + Edit Symbol Redigér symbol + Expand Brush Strokes Udvid penselstrøg + New Brush… Ny pensel… + New Symbol Nyt symbol + New Symbol… Nyt symbol… + Place Symbol Instance Placér symbolforekomst + Redefine Symbol Omdefinér symbol + Remove Brush Stroke Fjern penselstrøg + Replace Symbol Erstat symbol + Select All Instances Markér alle forekomster + Symbol Sprayer Symbolsprøjter + Symbolism Tool Symbolværktøj + Image Trace Presets Forudindstillinger til billedsporing + Live Paint Bucket Live Paint-spand + Live Paint Bucket (Stroke) Live Paint-spand (kontur) + Live Paint Info Live Paint-info + Shape Builder Formbygger + Shape Builder Regions Formbygger-områder + Image Trace View Visning af billedsporing + Edge Kant + Clipboard as PDF Udklipsholder som PDF + Clipboard as PNG Udklipsholder som PNG + Clipboard as Text Udklipsholder som tekst + Clipboard Formats Udklipsholderformater + Load Image into Clipboard Indlæs billede i udklipsholder + Load Metafile into Clipboard Indlæs metafil i udklipsholder + Load PDF into Clipboard Indlæs PDF i udklipsholder + Load Text into Clipboard Indlæs tekst i udklipsholder + Clipboard as SVG Udklipsholder som SVG + Load SVG into Clipboard Indlæs SVG i udklipsholder + Swatch Conflicts Farveprøvekonflikter + Saturate Mæt + Convert Color Konvertér farve + Convert Document Color Mode Konvertér dokumentets farvetilstand + Gamut Warning Gamut-advarsel + Load Color Profile… Indlæs farveprofil… + Plates Plader + Spot Color Spotfarve + Add to Swatches Føj til farveprøver + Delete Theme Slet tema + Save Theme Gem tema + dash streg + Missing Mangler + [Normal Paragraph Style] [Normal afsnitstypografi] + Arc Bue + Auto Size New Area Type Tilpas automatisk størrelse på ny områdetekst + Create Path Opret sti + Create Text Opret tekst + Drawing Mode Tegningstilstand + Line Linje + Line Segment Linjesegment + Polar Grid Polært gitter + Polygon Polygon + Rectangular Grid Rektangulært gitter + Horizontal Dividers: Vandrette opdelingslinjer: + Vertical Dividers: Lodrette opdelingslinjer: + Rounded Rectangle Afrundet rektangel + Spiral Spiral + Export CSS Eksportér CSS + Generate CSS Generér CSS + Line Cut Linjeklip + Mirror & Cut Spejl og klip + Rectangle Cut Rektangelklip + Active Perspective Plane Aktiv perspektivplan + Delete Width Point Slet breddepunkt + Draw in Perspective Tegn i perspektiv + Move in Perspective Flyt i perspektiv + Move Plane Flyt plan + Nudge in Perspective Skub i perspektiv + Puppet Warp Marionetforvrængning + Puppet Warp Pins Marionetforvrængningsnåle + Show Perspective Grid Vis perspektivgitter + Width Point Breddepunkt + Width Profile Breddeprofil + Objects Objekter + Paths Stier + Add Guide Tilføj hjælpelinje + Edit Selection Redigér markering + Recall Selection Genkald markering + Remove Guide Fjern hjælpelinje + Saved Selections Gemte markeringer + Select Guides Markér hjælpelinjer + Move Guide to Layer Flyt hjælpelinje til lag + Blob Brush Klatpensel + Convert Anchor Point Konvertér ankerpunkt + Curvature Krumning + Delete Anchor Point Slet ankerpunkt + Eraser Viskelæder + Knife Kniv + Path Eraser Stiviskelæder + Pencil Blyant + Reshape Segment Omform segment + Split Path Opdel sti + Activate Document Aktivér dokument + Delete Slet + Document JSON Dokument-JSON + Duplicate Duplikér + Find Objects Find objekter + Inspect Document Inspicér dokument + Inspect Object Inspicér objekt + Begin Undo Group Start fortrydelsesgruppe + End Undo Group Afslut fortrydelsesgruppe + Apply Effect Anvend effekt + Duplicate Effect Duplikér effekt + Effect Options Effektindstillinger + Effects Effekter + Move Effect Flyt effekt + Remove Effect Fjern effekt + Expand Options Udvidningsindstillinger + File Info Filinfo + Batch Batch + DXF Info DXF-info + Export Document Eksportér dokument + Export DXF Eksportér DXF + Export EPS Eksportér EPS + Export for Screens Eksportér til skærme + Export for Screens Settings Indstillinger for eksport til skærme + Export PDF Eksportér PDF + File Formats Filformater + Flattener presets Forudindstillinger til fladgøring + Format Options Formatindstillinger + Layer Lag + Link Link + Model Model + Open Document Åbn dokument + Password Adgangskode + PDF Info PDF-info + PDF Settings PDF-indstillinger + Print presets Forudindstillinger til udskrivning + Save Document Gem dokument + Serialize Document Serialisér dokument + Shapes Former + Outlines Omrids + Artboard Kunstplade + Windows Vinduer + Delete Transparency Flattener Preset Slet fladgøringsforudindstilling + Export Transparency Flattener Presets Eksportér fladgøringsforudindstillinger + Import Transparency Flattener Presets Importér fladgøringsforudindstillinger + Save Transparency Flattener Preset Gem fladgøringsforudindstilling + Transparency Flattener Presets Fladgøringsforudindstillinger + to til + Missing Fonts Manglende skrifttyper + Find Font Files Find skrifttypefiler + Add Font Files Tilføj skrifttypefiler + Font List Skrifttypeliste + Fonts in Document Skrifttyper i dokumentet + Refresh Font List Opdatér skrifttypeliste + Replace Font Erstat skrifttype + Select Text by Font Markér tekst efter skrifttype + Add Freeform Line Tilføj friformlinje + Add Freeform Point Tilføj friformpunkt + Delete Freeform Point Slet friformpunkt + Edit Freeform Point Redigér friformpunkt + Freeform Gradient Friformsgradient + Select Freeform Point Markér friformpunkt + Split Freeform Line Opdel friformlinje + Gradient Vector Gradientvektor + Select Gradient Stop Markér gradientstop + Create Graph Opret graf + Graph Data Grafdata + Graph Type Graftype + Vector Halftone Vektorraster + spot spot + Text Tekst + Artboard Options Indstillinger for kunstplade + Artboard Options… Indstillinger for kunstplade… + Collect in New Layer Saml i nyt lag + Delete Artboard Slet kunstplade + Delete Selection Slet markering + Duplicate Selection Duplikér markering + Highlight Rows Fremhæv rækker + Layer Options Lagindstillinger + Layer Options… Lagindstillinger… + Make/Release Clipping Mask Opret/frigiv beskæringsmaske + Move Rows Flyt rækker + New Artboard Ny kunstplade + New Layer… Nyt lag… + New Sublayer… Nyt underlag… + Paste Remembers Layers Indsættelse husker lag + Reorder Omarrangér + Select Art Markér grafik + Set Current Layer Angiv aktuelt lag + Flatten Artwork Fladgør grafik + Locate Object Lokalisér objekt + Lock Others Lås andre + Merge Selected Flet valgte + Outline Others Vis andre som omrids + Preview All Layers Forhåndsvis alle lag + Release to Layers (Build) Frigiv til lag (opbyg) + Release to Layers (Sequence) Frigiv til lag (sekvens) + Reverse Order Omvend rækkefølge + Show All Layers Vis alle lag + Unlock All Layers Lås alle lag op + English: USA Engelsk: USA + Move Flyt + Character Style Tegntypografi + Paragraph Style Afsnitstypografi + Style Typografi + Create New Library Opret nyt bibliotek + Add to Library Føj til bibliotek + Delete Library Slet bibliotek + Delete Library Item Slet bibliotekselement + Import Library Importér bibliotek + Export Library Eksportér bibliotek + New Library Group Ny biblioteksgruppe + Rename Library Group Omdøb biblioteksgruppe + Delete Library Group Slet biblioteksgruppe + Move Library Item to Group Flyt bibliotekselement til gruppe + Rename Library Omdøb bibliotek + Show Library Vis bibliotek + Use Library Item Brug bibliotekselement + Graphic Grafik + Check Links Kontrollér links + Embed Image Indlejr billede + Go To Link Gå til link + Link Info Linkinfo + Placement Options Placeringsindstillinger + Relink Genopret link + Unembed Fjern indlejring + Add Mesh Line Tilføj meshlinje + Add Spine Point Tilføj rygradpunkt + Blend Info Blandingsinfo + Blend Options Blandingsindstillinger + Delete Mesh Point Slet meshpunkt + Delete Spine Point Slet rygradpunkt + Envelope Info Konvolutinfo + Envelope Options Konvolutindstillinger + Expand Gradient Mesh Udvid gradientmesh + Make Envelope Opret konvolut + Move Envelope Mesh Point Flyt konvolut-meshpunkt + Move Mesh Point Flyt meshpunkt + Move Spine Point Flyt rygradpunkt + Reshape Spine Segment Omform rygradsegment + Reset with Mesh Nulstil med mesh + Reset with Warp Nulstil med forvrængning + Set Mesh Point Color Angiv farve for meshpunkt + Edit Opacity Mask Redigér opacitetsmaske + Opacity Mask Opacitetsmaske + Stop Editing Opacity Mask Stop redigering af opacitetsmaske + View Opacity Mask Vis opacitetsmaske + New Opacity Masks Are Clipping Nye opacitetsmasker er beskærende + New Opacity Masks Are Inverted Nye opacitetsmasker er inverterede + Rasterize Rasterisér + Transform Each Transformér hver + New Art Appearance Udseende for ny grafik + New Art Has Basic Appearance Ny grafik har grundlæggende udseende + Delete Preset Slet forudindstilling + High (300 ppi) Høj (300 ppi) + Medium (150 ppi) Medium (150 ppi) + New Document Presets Forudindstillinger til nye dokumenter + Phone 390×844 Telefon 390×844 + Postcard Postkort + Grid by Row Gitter efter række + Grid by Column Gitter efter kolonne + Arrange by Row Arranger efter række + Arrange by Column Arranger efter kolonne + Save Preset Gem forudindstilling + Screen (72 ppi) Skærm (72 ppi) + Social Square Post 1080×1080 Socialt firkantet opslag 1080×1080 + Distribute Fordel + Distribute Spacing Fordel afstand + Enter Isolation Mode Start isoleringstilstand + Live Shape Properties Egenskaber for dynamiske former + Nudge Skub + Object Properties Objektegenskaber + Reflect Spejl + Rotate Rotér + Set Bounds Angiv afgrænsning + Shear Vrid + Disable Opacity Mask Deaktivér opacitetsmaske + Enable Opacity Mask Aktivér opacitetsmaske + Link Opacity Mask Tilknyt opacitetsmaske + Make Opacity Mask Opret opacitetsmaske + Opacity Mask Info Opacitetsmaske-info + Opacity Mask Options Indstillinger for opacitetsmaske + Page Isolated Blending Isoleret blanding for siden + Page Knockout Group Knockout-gruppe for siden + Release Opacity Mask Frigiv opacitetsmaske + Transparency Info Gennemsigtigheds-info + Unlink Opacity Mask Frakobl opacitetsmaske + Overprint Overprint + Package Pakke + Apply Last Color Anvend seneste farve + Apply Last Gradient Anvend seneste gradient + Complement Komplementér + Default Fill and Stroke Standardudfyldning og -kontur + Fill Udfyldning + Fill and Stroke Udfyldning og kontur + Invert Invertér + Recent Colors Seneste farver + Swap Fill and Stroke Byt udfyldning og kontur + Toggle Fill/Stroke Focus Skift fokus mellem udfyldning/kontur + Character / Paragraph Tegn / Afsnit + Copy Artboard Kopiér kunstplade + Cut Artboard Klip kunstplade + Move Artboard Up/Down Flyt kunstplade op/ned + Add Anchor (Pen) Tilføj anker (Pen) + Add Anchor Point Tilføj ankerpunkt + Average Gennemsnit + Close Path Luk sti + Convert Anchor Points Konvertér ankerpunkter + Cut Path Klip sti + Delete Anchor Points Slet ankerpunkter + Move Anchors Flyt ankre + Move Direction Handle Flyt retningshåndtag + Reshape Omform + Set Anchors Angiv ankre + Minus Front Minus front + Offset Path Forskyd sti + Simplify Forenkl + Split Into Grid Opdel i gitter + Total: I alt: + Add Guides Tilføj hjælpelinjer + Unite Foren + Delete Pattern Slet mønster + Done Udført + Expand Repeat Udvid gentagelse + List Patterns Oplist mønstre + Repeat Options Indstillinger for gentagelse + Save a Copy Gem en kopi + Transform Patterns Transformér mønstre + Delete PDF Preset Slet PDF-forudindstilling + Export PDF Presets Eksportér PDF-forudindstillinger + Import PDF Presets Importér PDF-forudindstillinger + PDF Preset PDF-forudindstilling + PDF Presets PDF-forudindstillinger + Save PDF Preset Gem PDF-forudindstilling + Define Perspective Grid Definér perspektivgitter + Delete Perspective Grid Preset Slet forudindstilling til perspektivgitter + Export Perspective Grid Presets Eksportér forudindstillinger til perspektivgitter + Import Perspective Grid Presets Importér forudindstillinger til perspektivgitter + Perspective Grid Options Indstillinger for perspektivgitter + Perspective Grid Preset Forudindstilling til perspektivgitter + Perspective Grid Presets Forudindstillinger til perspektivgitter + Save Perspective Grid Preset Gem forudindstilling til perspektivgitter + Image Info Billedinfo + Load Place Cursor Indlæs placeringsmarkør + Placed File Info Info om placeret fil + Install Plug-in Installér plug-in + List Plug-ins Oplist plug-ins + Plug-in Info Plug-in-info + Remove Plug-in Fjern plug-in + Run Plug-in Kør plug-in + Smooth Udglat + Corner Hjørne + White Hvid + 30° Angles 30°-vinkler + 45° Angles 45°-vinkler + 60° Angles 60°-vinkler + 90° & 45° & 30° Angles 90° & 45° & 30°-vinkler + 90° & 45° Angles 90° & 45°-vinkler + 90° Angles 90°-vinkler + Additional Plug-ins Folder Yderligere mappe med plug-ins + Additional Fonts Folder Yderligere mappe med skrifttyper + Alignment Guides Justeringshjælpelinjer + Anchor Points, Handle, and Bounding Box Display Visning af ankerpunkter, håndtag og afgrænsningsboks + Anchor/Path Labels Anker-/stietiketter + Angles Vinkler + Animated Zoom Animeret zoom + Anti-aliased Artwork Antialiaseret grafik + Appearance Panel Udseende-panel + Ask When Modified Spørg ved ændringer + Auto-Collapse Iconic Panels Fold ikoniske paneler automatisk sammen + Show Tool Group Labels Vis etiketter for værktøjsgrupper + Automatically Automatisk + Automatically Save Recovery Data Gem automatisk gendannelsesdata + Baseline Shift Grundlinjeforskydning + Brightness Lysstyrke + Canvas Color Lærredsfarve + Centimeters Centimeter + Command Click to Select Objects Behind Command-klik for at markere objekter bagved + Constrain Angle Fastlåsningsvinkel + Constrain Width and Height Proportions Fasthold forholdet mellem bredde og højde + Construction Guides Konstruktionshjælpelinjer + Curvature Tool Krumningsværktøj + Data Recovery Datgendannelse + Default Language Standardsprog + Disable Auto Add/Delete Deaktivér automatisk tilføjelse/sletning + Display All Blacks Accurately Vis alle sorte nøjagtigt + Display All Blacks as Rich Black Vis alle sorte som rich black + Display Bitmaps as Anti-aliased Images in Pixel Preview Vis bitmaps som antialiaserede billeder i pixelforhåndsvisning + Display Options Visningsindstillinger + Display Print Size at 100% Zoom Vis udskriftsstørrelse ved 100 % zoom + Double Click To Isolate Dobbeltklik for at isolere + Dutch Hollandsk + East Asian Type Østasiatisk tekst + Enable Background Export Aktivér eksport i baggrunden + Enable Background Save Aktivér lagring i baggrunden + Enable in-menu font previews Aktivér skrifttypeforhåndsvisning i menuer + Enable Missing Glyph Protection Aktivér beskyttelse mod manglende glyffer + Enable Rubber Band for Aktivér gummibånd for + Enable Touch Gestures Aktivér berøringsbevægelser + Enable Touch Workspace Aktivér touch-arbejdsområde + English: UK Engelsk: UK + Every (minutes) Hvert (minut) + Exceptions (comma separated) Undtagelser (kommasepareret) + Feet Fod + Files Filer + Fill New Type Objects With Placeholder Text Udfyld nye tekstobjekter med pladsholdertekst + Folder Mappe + Font Preview Size Størrelse på skrifttypeforhåndsvisning + Get Preferences Hent indstillinger + GPU Performance GPU-ydeevne + Graphics Processor Grafikprocessor + Gridline every Gitterlinje hver + Grids In Back Gitter i baggrunden + Handles Håndtag + Hide Corner Widget for angles greater than Skjul hjørne-widget for vinkler større end + High Performance (discrete) Høj ydeevne (diskret) + Highlight Alternate Glyphs Fremhæv alternative glyffer + Highlight anchors on mouse over Fremhæv ankerpunkter, når musen holdes over + History States Historiktilstande + Hollow Hul + Identify Objects By Identificér objekter ud fra + Include SVG Code Medtag SVG-kode + Italian Italiensk + Keyboard Increment Tastaturtrin + Language Options Sprogindstillinger + Large Stor + Large Tabs Store faner + Legacy vector clipboard Ældre vektorudklipsholder + Legacy vector clipboard (no transparency) Ældre vektorudklipsholder (uden gennemsigtighed) + Line Color Linjefarve + Lines Linjer + List Preferences Oplist indstillinger + Manually Manuelt + Mark Older Files as [Converted] When Opened Markér ældre filer som [konverteret] ved åbning + Match User Interface Brightness Match lysstyrken i brugergrænsefladen + Light Gray Lysegrå + Medium Gray Mellemgrå + Dark Gray Mørkegrå + Measurement Labels Måleetiketter + Meters Meter + Move Locked and Hidden Artwork with Artboard Flyt låst og skjult grafik med kunstpladen + Number of Recent Files to Display Antal seneste filer, der vises + Number of Recent Fonts Antal seneste skrifttyper + Numbers Without Units Are Points Tal uden enheder er punkter + Object Highlighting Objektfremhævning + Object Name Objektnavn + Object Selection by Path Only Objektmarkering kun via sti + On Copy Ved kopiering + On Paste Ved indsættelse + On Screen På skærmen + Open Documents As Tabs Åbn dokumenter som faner + Options Indstillinger + Output All Blacks Accurately Udskriv alle sorte nøjagtigt + Output All Blacks as Rich Black Udskriv alle sorte som rich black + Override Character Color Tilsidesæt tegnfarve + Paste Offset Indsætningsforskydning + Pattern Editing Mønsterredigering + Pen Tool Pen-værktøj + Picas Pica + Portuguese Portugisisk + Preserve Appearance and Overprints Bevar udseende og overprint + Preserve Paths Bevar stier + Primary Primær + Printing / Exporting Udskrivning / eksport + Real-time Drawing and Editing Tegning og redigering i realtid + Render Threads (-1 = Automatic) Renderingstråde (-1 = automatisk) + Reset Preferences Nulstil indstillinger + Scale Corners Skalér hjørner + Scale Cursor Proportional to UI Skalér markør proportionalt med brugergrænsefladen + Scale Strokes & Effects Skalér konturer & effekter + Scratch Disks Midlertidige diske + Secondary Sekundær + Select Same Tint % Markér samme farvetone % + Set Preference Angiv indstilling + Show East Asian Options Vis østasiatiske indstillinger + Show Font Names in English Vis skrifttypenavne på engelsk + Show handles when multiple anchors are selected Vis håndtag, når flere ankerpunkter er markeret + Show Indic Options Vis indiske indstillinger + Show Pixel Grid (Above 600% Zoom) Vis pixelgitter (over 600 % zoom) + Show Slice Numbers Vis udsnitsnumre + Show The Home Screen When No Documents Are Open Vis startskærmen, når ingen dokumenter er åbne + Show Tool Tips Vis værktøjstips + Size/Leading Størrelse/linjeafstand + Snapping Tolerance Snap-tolerance + Spacing Guides Afstandshjælpelinjer + Spanish Spansk + Subdivisions Underinddelinger + Templates Folder Skabelonmappe + Tile Edge Color Kantfarve for fliser + Tolerance Tolerance + Tracking Sporing + Transform Panel Transformationspanel + Transform Pattern Tiles Transformer mønsterfliser + Transform Tools Transformationsværktøjer + Turn off Data Recovery for complex documents Slå datagendannelse fra for komplekse dokumenter + Type Object Selection by Path Only Markering af tekstobjekter udelukkende via kurve + UI Scaling UI-skalering + Use Compression Anvend komprimering + Use Japanese Crop Marks Anvend japanske beskæringsmærker + Use Low Resolution Proxy for Linked EPS Anvend proxy med lav opløsning til koblede EPS-filer + Use Precise Cursors Anvend præcise markører + Use Preview Bounds Anvend visningsgrænser + When pasting text Når tekst indsættes + XML ID XML-id + Yards Yards + Zoom to Selection Zoom til markering + Zoom with Mouse Wheel Zoom med musehjul + Scrub Numeric Fields by Dragging Juster numeriske felter ved at trække + Linux Display Server Linux-visningsserver + Print Preview Udskriftsvisning + Print Setup Udskriftsopsætning + Bitmap Bitmap + Delete Print Preset Slet udskriftsforudindstilling + Export Print Presets Eksportér udskriftsforudindstillinger + Import Print Presets Importér udskriftsforudindstillinger + Print Preset Udskriftsforudindstilling + Print Presets Udskriftsforudindstillinger + Save Print Preset Gem udskriftsforudindstilling + Print Tiling Pages Udskriv sideopdelte sider + Document Raster Effects Settings Indstillinger for dokumentets rasteriseringseffekter + Artwork Colors Farver i grafik + Randomize Colors Tilfældiggør farver + Recolor Artwork Omfarv grafik + Reduce Colors Reducér farver + Discard Recovered Documents Kassér gendannede dokumenter + Recovery Data Gendannelsesdata + Restore Recovered Documents Genopret gendannede dokumenter + Save Recovery Data Gem gendannelsesdata + Untitled Uden navn + Add to Selection Føj til markering + Select Anchors Markér ankerpunkter + Select Segments Markér segmenter + Select Objects Markér objekter + Toggle Selection Skift markering + Create Slice Opret udsnit + Delete Slices Slet udsnit + Divide Slices Opdel udsnit + List Slices Oplist udsnit + Move Slices Flyt udsnit + Select Slices Markér udsnit + Set Slice Rectangle Angiv udsnitsrektangel + Slice Options Udsnitsindstillinger + Add to Profiles Føj til profiler + Delete Profile Slet profil + Reset Profiles Nulstil profiler + Stroke Options Konturindstillinger + Width Profiles Breddeprofiler + Apply Graphic Style Anvend grafikstil + Break Link to Graphic Style Bryd link til grafikstil + Delete Graphic Style Slet grafikstil + Duplicate Graphic Style Duplikér grafikstil + Graphic Style Options Indstillinger for grafikstil + Graphic Styles Options Indstillinger for grafikstile + Default Graphic Style Standardgrafikstil + Black Outline Sort kontur + Heavy Ink Kraftigt blæk + Sunshine Solskin + List Graphic Styles Oplist grafikstile + Merge Graphic Styles Flet grafikstile + Move Graphic Style Flyt grafikstil + New Graphic Style Ny grafikstil + Redefine Graphic Style Omdefinér grafikstil + Rename Graphic Style Omdøb grafikstil + Add to Graphic Styles Føj til grafikstile + Graphic Style Library Grafikstilbibliotek + Add Used Colors Tilføj anvendte farver + Delete Swatch Slet farveprøve + Duplicate Swatch Duplikér farveprøve + Merge Swatches Flet farveprøver + Move Swatch Flyt farveprøve + New Color Group Ny farvegruppe + New Swatch Ny farveprøve + Select All Unused Markér alle ubrugte + Sort by Kind Sortér efter type + Sort by Name Sortér efter navn + Spot Colors Spotfarver + Spot Colors (0) Spotfarver (0) + Swatch Options Indstillinger for farveprøve + Ungroup Color Group Afgruppér farvegruppe + Copy to User Defined Kopiér til brugerdefinerede + Swatch Library Farveprøvebibliotek + Clear All Tabs Ryd alle tabulatorer + Set Tab Stops Angiv tabulatorstop + Tab Stops Tabulatorstop + Flare Flare + Rays Stråler + Rectangle Rektangel + Paintbrush Malerpensel + Pen Pen + Small Lille + Black and White Logo Sort/hvid-logo + High Fidelity Photo Foto med høj trofasthed + Low Fidelity Photo Foto med lav trofasthed + 3 Colors 3 farver + 6 Colors 6 farver + Shades of Gray Gråtoner + Sketched Art Skitseret grafik + Silhouettes Silhuetter + Line Art Stregtegning + Technical Drawing Teknisk tegning + Flat Logo Fladt logo + Keep Formatting Bevar formatering + Keep Plain Text Bevar almindelig tekst + Clip Beskær + PDF PDF + no document intet dokument + Blue Blå + Brown Brun + Gray Grå + Orange Orange + Pink Lyserød + Purple Lilla + Custom Brugerdefineret + Document Setup Dokumentopsætning + French Fransk + German Tysk + Inches Tommer + Japanese Japansk + Millimeters Millimeter + Points Punkter +effect Points Punkter + Adjust Colors Juster farver + Place Placér + Discard Empty Type Kassér tom tekst + Copyright Copyright + Other Andet + Print Udskriv + Solid Ensfarvet + Difference Difference + Flatten Transparency Fladgør gennemsigtighed + Fonts Skrifttyper + Off Fra + above over + Square Firkant + Update Links Opdatér links + Liquify Liquidgør + Corner Radius Hjørneradius + Move Guide Flyt hjælpelinje +axis Both Begge + Letter Letter + Portrait Stående + Recent Seneste + Saved Gemt + Web 1920×1080 Web 1920×1080 + Yellow Gul + Auto Auto + Center Midt + Transform in Perspective Transformer i perspektiv + Grayscale Gråtoner + Embed Integrér + Star Stjerne + Power Saving (integrated) Strømbesparende (integreret) + Target Mål + X11 (XWayland) X11 (XWayland) + Print as Bitmap Udskriv som bitmap + Draft Udkast + Default Standard + [VectorCraft Default] [VectorCraft Standard] + Magenta Magenta + Page Side + Print Tiling Sideopdeling ved udskrivning + Edit Color Group Redigér farvegruppe + Marks Mærker + Color Mode Farvetilstand + Templates Skabeloner + Halo Halo + Multiply Multiplikation + Red Rød + Grays Gråtoner + Color Group Farvegruppe + Library Bibliotek + English English + List Folder Oplist mappe + Area Type Options Indstillinger for arealtekst + Reshape Type Area Omform tekstareal + Scale Skalér + Feet & Inches Fod & tommer + Pixels Pixels + Pixel Pixel + More Mere + Show Vis + Copy Width Point Kopiér breddepunkt + Duplicate Artboard Duplikér tegneflade + Area / Path Type Arealtekst / kurvetekst + Decrease Font Size Formindsk skriftstørrelse + Increase Font Size Forøg skriftstørrelse + Step Type Trappe tekst + Get Text Range Hent tekstområde + Punctuation Tegnsætning + [Normal Character Style] [Normal tegnstil] + Text Wrap Options Indstillinger for tekstombrydning + Threads Tekstforbindelser + Orientation Orientering + Set Text Angiv tekst + Insert Character Indsæt tegn + Replace Erstat + Type on a Path Options Indstillinger for tekst på kurve + Highlight Variable Fremhæv variabel + New Variable Ny variabel + List Variables Oplist variabler + Bind Variable Tilknyt variabel + New Data Set Nyt datasæt + List Data Sets Oplist datasæt + Select Data Set Markér datasæt + Edit Data Set Redigér datasæt + Visibility Synlighed + New View Ny visning + Saved Views Gemte visninger + Magic Wand Options Indstillinger for tryllestav + Magic Wand Settings Tryllestavindstillinger + Delete Save for Web Preset Slet forudindstilling til Gem til web + Save for Web Gem til web + Save for Web Presets Forudindstillinger til Gem til web + GIF, 128 colours GIF, 128 farver + Save for Web Preview Forhåndsvisning af Gem til web + Save for Web Settings Indstillinger for Gem til web + Save Save for Web Preset Gem forudindstilling til Gem til web + Eyedropper Options Indstillinger for pipet + Free Distort Fri forvrængning + Move Artboard Flyt tegneflade + Sample Color Tag farveprøve + Angle Vinkel + Black Sort + Bottom Bund + Bounds Grænser + Colors Farver + Cyan Cyan + Discard Kassér + Document Dokument + Encoding Kodning + Frequency Frekvens + Height Højde + Image Billede + Ink Blæk + Lab Lab + Medium Medium + Name Navn + None Ingen + Normal Normal + Pages Sider + Regular Regular + Source Sans 3 Source Sans 3 + Template Skabelon + Top Øverst + Width Bredde + : : +effect Regular Regular + Saving Poster.vectorcraft Gemmer Poster.vectorcraft + Community Fællesskab + Hide Bar Skjul linjen + More Options Flere indstillinger + Show Properties Panel Vis egenskabspanel + Isolate Isolér + Lock (⌘2) Lås (⌘2) + New file Ny fil + Open Åbn + Quickly start a new file Start hurtigt en ny fil + Recent Files Seneste filer + Vector illustration — fast, open, scriptable. Vektorillustration — hurtig, åben, scriptbar. + Welcome to VectorCraft Velkommen til VectorCraft + ArtCraft website ArtCraft-websted + Discord Discord + Join our Discord Deltag i vores Discord + Join the ArtCraft community on Discord (discord.gg/artcraft) Deltag i ArtCraft-fællesskabet på Discord (discord.gg/artcraft) + Source code on GitHub Kildekode på GitHub + All Tools Alle værktøjer + Draw Tegn + Reset Nulstil + — missing — mangler + (closest) (nærmeste) + Change Ret + Change All Ret alle + Find Find + Find Font Find skrifttype + Fonts in Document: {count} Skrifttyper i dokumentet: {count} + In the selected objects I de markerede objekter + Find in Folder… Find i mappe… + Replace With Font Erstat med skrifttype + — substituted by {style} — erstattet af {style} + — {n} characters from another font — {n} tegn fra en anden skrifttype + Drag to float the panel group Træk for at gøre panelgruppen flydende + Put back in the dock Sæt tilbage i dokken + All Scripts Alle scripts + Any Kind Enhver type + Decorative Dekorative + Favorites Favoritter + Latin Latinsk + Thai Thai + Monospaced Monospaced + No matching fonts Ingen matchende skrifttyper + Rounded Afrundede + Sans Serif / Gothic Sans serif / gotisk + Script / Brush Script / pensel + Serif / Mincho Serif / Mincho + Changes made since it was last saved will be lost. Ændringer, der er foretaget, siden den sidst blev gemt, går tabt. + Can't open “{name}”. Kan ikke åbne »{name}«. + Can't save “{name}”. Kan ikke gemme »{name}«. + Can't export “{name}”. Kan ikke eksportere »{name}«. + Downloaded {what} Downloadede {what} + Exported {count} file(s) to {folder} Eksporterede {count} fil(er) til {folder} + Exported {what} Eksporterede {what} + Exported {what} with {} note(s): {first} Eksporterede {what} med {} bemærkning(er): {first} + Exporting {} Eksporterer {} + Imported {what}: {} Importerede {what}: {} + Installed plug-in {} Installerede plug-in'en {} + Opened with {} note(s): {} Åbnede med {} bemærkning(er): {} + Revert to the saved version of “{name}”? Gendan den gemte version af »{name}«? + Replace “{name}”, the file this document was opened from? Erstat »{name}«, den fil, dette dokument blev åbnet fra? + Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Da den blev åbnet, blev det udeladt, som VectorCraft ikke kan læse endnu ({what}), så hvis du erstatter den, går det tabt for altid. Gem under et andet navn for at beholde det. + Saved {path} Gemte {path} + Saved {path} with {} note(s): {first} Gemte {path} med {} bemærkning(er): {first} + Saving Gemmer + Saving {} Gemmer {} + Search commands and tools… Søg i kommandoer og værktøjer… + Dashed Line Stiplet linje + Panel menu Panelmenu + Unembed… Ophæv integrering… + Applies the next time VectorCraft starts. Anvendes næste gang VectorCraft starter. + Enable Contextual Task Bar Aktivér kontekstuel opgavelinje + Next Næste + OK OK + Preferences Præferencer + Previous Forrige + Restore every preference to its default (applied on OK) Gendan alle præferencer til standard (anvendes ved OK) + Smaller ↔ Larger Mindre ↔ Større + Smart Guides (View → Smart Guides) Smarte hjælpelinjer (Vis → Smarte hjælpelinjer) + Couldn't save recovery data: {e} Kunne ikke gemme gendannelsesdata: {e} + Discarded {} recovered document(s) Kasserede {} gendannede dokument(er) + Restored {} recovered document(s): save them to keep them Genoprettede {} gendannede dokument(er): gem dem for at beholde dem + Saved recovery data for {title} Gemte gendannelsesdata for {title} + Saving recovery data for {} Gemmer gendannelsesdata for {} + Alt Alt + Backspace Backspace + Cmd Cmd + Ctrl Ctrl + Shift Skift + Couldn't copy to the system clipboard: {e} Kunne ikke kopiere til systemets udklipsholder: {e} + Couldn't paste from the clipboard: {e} Kunne ikke indsætte fra udklipsholderen: {e} + Channel Kanal + Circle Cirkel + Clip to Art Beskær til grafik + Colorize Farvelæg + Gamma Gamma + Hue Farvetone + Input Black Input-sort + Keep Original Behold original + Lightness Lyshed + Mono Mono + Output White Output-hvid + Saturation Mætning + Screens Skærme + Add Swatches Tilføj farveprøver + Apply to All Anvend på alle + Swatch Conflict Konflikt mellem farveprøver + Axis: Akse: + Free Fri + Keep: Behold: + Search Søg + Classic Defaults Klassiske standarder + Uniform Ensartet + Include Blacks with CMY: Medtag sort i CMY: + Recolor Omfarv + Swatch Tiles Farveprøvefelter + Baseline Baslinje + Flip: Vend: + Spacing: Mellemrum: + Output Output + Export… Eksportér… + Import… Importér… + Load Profile… Indlæs profil… + Embedded Integreret + Image Tracing Billedsporing + Linked File Koblet fil + Preset: Forudindstilling: + View: Visning: + Tracing Result Sporingsresultat + Outlines with Source Image Konturer med kildebillede + SVG Options SVG-indstillinger + Maximize Maksimér + Restore Gendan + Align Objects: Juster objekter: + Default Fill and Stroke (D) Standardfyld og -kontur (D) + Fill (X), double-click for the Color Picker Fyld (X), dobbeltklik for at åbne farvevælgeren + Opacity: Opacitet: + Shape Modes: Formtilstande: + Stroke (X), double-click for the Color Picker Kontur (X), dobbeltklik for at åbne farvevælgeren + Stroke: Kontur: + Swap Fill and Stroke (Shift+X) Ombyt fyld og kontur (Skift+X) + Manage Workspaces Administrér arbejdsområder + Name: Navn: + New Ny + New Workspace Nyt arbejdsområde + Rename Omdøb + Save the current layout under this name Gem det aktuelle layout under dette navn + Saves the current bars, toolbar and panel layout. Gemmer de aktuelle linjer, værktøjslinjen og panellayoutet. + Workspace {i} Arbejdsområde {i} + Glowing Edges Glødende kanter + Edge Width Kantbredde + Edge Brightness Kantens lysstyrke + Smoothness Glatthed + Color Halftone Farvehalvtone + Mezzotint Mezzotint + Pointillize Pointillisme + Max. Radius Maks. radius + Channel 1 Kanal 1 + Channel 2 Kanal 2 + Channel 3 Kanal 3 + Channel 4 Kanal 4 + Cell Size Cellestørrelse + Radial Blur Radial sløring + Smart Blur Smart sløring + Unsharp Mask Uskarp maskering + Gaussian Blur Gaussisk sløring + Method Metode + Quality Kvalitet + Radius Radius + Apply Character Style Anvend tegnstil + Apply Paragraph Style Anvend afsnitsstil + Character Style Options Indstillinger for tegnstil + Flip Vend + Couldn't {} {name}: {e} Kunne ikke {} {name}: {e} + Create PDF layers from top-level layers Opret PDF-lag fra lag på øverste niveau + Delete Character Style Slet tegnstil + Delete Layer Slet lag + Delete Paragraph Style Slet afsnitsstil + Duplicate Character Style Duplikér tegnstil + Duplicate Layer Duplikér lag + Duplicate Paragraph Style Duplikér afsnitsstil + Spacing Mellemrum + Mojikumi Mojikumi + N pt per N pt per + New Character Style Ny tegnstil + New Paragraph Style Ny afsnitsstil + Paragraph Style Options Indstillinger for afsnitsstil + Redefine Character Style Omdefinér tegnstil + Redefine Paragraph Style Omdefinér afsnitsstil + Rename Character Style Omdøb tegnstil + Rename Paragraph Style Omdøb afsnitsstil + Select All Art on Layer Markér al grafik på laget + Settings… Indstillinger… + Joins Sammenføjninger + Joins: Sammenføjninger: + Distance Afstand + Distance: Afstand: + Gutter Spalteafstand + Gutter: Spalteafstand: + Length Længde + Length: Længde: + Points: Punkter: + Scale X Skala X + Scale X: Skala X: + Scale Y Skala Y + Scale Y: Skala Y: + Shear: Vridning: + Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required. Træk grafik, farver og tekststile herhen for at genbruge dem på tværs af dokumenter. Biblioteker gemmes på denne maskine — der kræves ingen konto. + De-Interlace De-interlace + Eliminate Eliminér + Create New Fields by Opret nye felter ved + Craquelure Craquelure + Grain Korn + Mosaic Tiles Mosaikfliser + Patchwork Patchwork + Stained Glass Blyindfattet glas + Texturizer Teksturgiver + Crack Spacing Revneafstand + Crack Depth Revnedybde + Crack Brightness Revneens lysstyrke + Grain Type Korntype + Tile Size Flisestørrelse + Grout Width Fugebredde + Lighten Grout Lysere fuge + Square Size Kvadratstørrelse + Relief Relief + Border Thickness Kanttykkelse + Light Intensity Lysintensitet + Light Direction Lysretning + Value Axis Værdiakse + Diffuse Glow Diffust skær + Glass Glas + Ocean Ripple Havkrusning + Graininess Kornethed + Glow Amount Skærmængde + Clear Amount Klar mængde + Ripple Size Krusningsstørrelse + Ripple Magnitude Krusningsstyrke + Accented Edges Accenterede kanter + Angled Strokes Vinklede strøg + Dark Strokes Mørke strøg + Ink Outlines Blækonturer + Spatter Sprøjtning + Sprayed Strokes Sprøjtede strøg + Sumi-e Sumi-e + Direction Balance Retningsbalance + Stroke Length Strøglængde + Sharpness Skarphed + Strength Styrke + Balance Balance + Black Intensity Sort intensitet + White Intensity Hvid intensitet + Dark Intensity Mørk intensitet + Spray Radius Sprøjteradius + Stroke Direction Strøgretning + Stroke Width Strøgbredde + Stroke Pressure Strøgtryk +imageTrace Outline Kontur +@plural {n} item|{n} items {n} element|{n} elementer +@plural Color Table ({n} color)|Color Table ({n} colors) Farvetabel ({n} farve)|Farvetabel ({n} farver) +@plural Delete this {n} graphic style?|Delete these {n} graphic styles? Vil du slette denne {n} grafikstil?|Vil du slette disse {n} grafikstile? +@plural {n} Layer|{n} Layers {n} lag|{n} lag +@plural {n} Object|{n} Objects {n} objekt|{n} objekter +@plural {n} area highlighted|{n} areas highlighted {n} område fremhævet|{n} områder fremhævet +@plural {n} color|{n} colors {n} farve|{n} farver +@plural {n} fill|{n} fills {n} fyld|{n} fyld +@plural {n} glyph|{n} glyphs {n} glyf|{n} glyffer +@plural {n} object highlighted|{n} objects highlighted {n} objekt fremhævet|{n} objekter fremhævet +@plural {n} star|{n} stars {n} stjerne|{n} stjerner +@plural {n} step|{n} steps {n} trin|{n} trin +@plural {n} stroke|{n} strokes {n} kontur|{n} konturer +@msg no library `{id}` intet bibliotek `{id}` +@msg select some type first markér først noget tekst +@msg {_1} has no {_2} style {_1} har ingen {_2}-stil +@msg a layer couldn't be read et lag kunne ikke læses +@msg it has more than one artboard den har mere end én tegneflade +@msg it has no page den har ingen side +@msg it has too many text objects den har for mange tekstobjekter +@msg it has too much text den har for meget tekst +@msg its editing data doesn't say where its artboard is dens redigeringsdata siger ikke, hvor dens tegneflade er +@msg its editing data has no layers dens redigeringsdata har ingen lag +@msg its editing data is damaged dens redigeringsdata er beskadiget +@msg its editing data is for a different page dens redigeringsdata er til en anden side +@msg its layers use {_1}, which aren't read yet dens lag bruger {_1}, som ikke kan læses endnu +@msg its page has text, and its layers have no text objects to put it in dens side har tekst, og dens lag har ingen tekstobjekter at lægge den i +@msg its type is to come in as outlines, which only its page has dens tekst kommer ind som konturer, hvilket kun dens side har +@msg the art on them differs from the page's in {_1}% of it grafikken på dem afviger fra sidens i {_1}% af den +@msg the art on them differs from the page's in {_1}% of it: they come from its editing data all the same grafikken på dem afviger fra sidens i {_1}% af den: de kommer alligevel fra dens redigeringsdata +@msg `to` must be "corner" or "smooth" `to` skal være "corner" eller "smooth" +@msg BMP files are RGB or grayscale: colorModel rgb or gray BMP-filer er RGB eller gråtoner: colorModel rgb eller gray +@msg Blend colors: select at least three objects with filled colours Bland farver: markér mindst tre objekter med udfyldte farver +@msg Combine Slices: the slices have no bounds Kombinér udsnit: udsnittene har ingen grænser +@msg Convert to Artboards: selection has no area Konvertér til tegneflader: markeringen har intet areal +@msg Convert to Shape: no rectangles or ellipses selected Konvertér til figur: ingen rektangler eller ellipser er markeret +@msg Copied the CSS of {rules} object(s) Kopierede CSS fra {rules} objekt(er) +@msg Copied the script report Kopierede scriptrapporten +@msg Corners edits the corners of a path Hjørner redigerer hjørnerne på en kurve +@msg Couldn't copy to the system clipboard: {e} Kunne ikke kopiere til systemets udklipsholder: {e} +@msg Couldn't open {name}: {e} Kunne ikke åbne {name}: {e} +@msg Couldn't place {name}: the document it was dropped on was closed Kunne ikke placere {name}: dokumentet, den blev sluppet på, var lukket +@msg Couldn't read {name}: {e} Kunne ikke læse {name}: {e} +@msg Couldn't save recovery data: {e} Kunne ikke gemme gendannelsesdata: {e} +@msg Create Outlines: select text objects Opret konturer: markér tekstobjekter +@msg Create from Guides: the document has no artboard Opret ud fra hjælpelinjer: dokumentet har ingen tegneflade +@msg Create from Guides: the guides make more than {MAX_PIECES} slices Opret ud fra hjælpelinjer: hjælpelinjerne danner mere end {MAX_PIECES} udsnit +@msg Create from Selection: the selection has no area Opret ud fra markering: markeringen har intet areal +@msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Datagendannelse har intet sted at opbevare kopier: angiv Præferencer → Filhåndtering → Datagendannelse → Mappe (recoveryFolder) +@msg direct-select anchor points to cut at (not the end points of open paths) direkte-markér ankerpunkter, der skal skæres i (ikke endepunkterne på åbne kurver) +@msg Divide Objects Below: select a path Opdel objekter nedenunder: markér en kurve +@msg Divide Objects Below: select exactly one path Opdel objekter nedenunder: markér præcis én kurve +@msg Downloaded {_1} file(s) Downloadede {_1} fil(er) +@msg Downloaded {what} Downloadede {what} +@msg Expand Appearance: the selection has a basic appearance Udvid udseende: markeringen har et grundlæggende udseende +@msg Expand: no brush strokes selected Udvid: ingen penselstrøg er markeret +@msg Expand: no gradient fills Udvid: ingen gradientfyld +@msg Expand: nothing to expand Udvid: intet at udvide +@msg Exported {count} file(s) to {folder} Eksporterede {count} fil(er) til {folder} +@msg Exported {path} Eksporterede {path} +@msg Flatten Transparency: select objects to flatten Fladgør gennemsigtighed: markér objekter, der skal fladgøres +@msg Flatten Transparency: {e} Fladgør gennemsigtighed: {e} +@msg Image Trace: the traced image is missing Billedsporing: det sporede billede mangler +@msg Imported {what}: {_1} Importerede {what}: {_1} +@msg Installed plug-in {_1} Installerede plug-in'en {_1} +@msg Internal error: {msg} (please report this bug) Intern fejl: {msg} (meld venligst denne fejl) +@msg Live Paint Merge: select a Live Paint group and the paths to add Live Paint-fletning: markér en Live Paint-gruppe og de kurver, der skal tilføjes +@msg Live Paint: select paths or compound paths Live Paint: markér kurver eller sammensatte kurver +@msg Live Paint: these paths are too degenerate to paint Live Paint: disse kurver er for degenererede til at blive malet +@msg Make Guides: select paths Opret hjælpelinjer: markér kurver +@msg No saved selections (Select → Save Selection…) Ingen gemte markeringer (Markér → Gem markering…) +@msg No saved views (View → New View…) Ingen gemte visninger (Vis → Ny visning…) +@msg Offset Path: select paths Forskyd kurve: markér kurver +@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Forskyd kurve: forskydningen efterlader intet af de markerede kurver (en indadgående forskydning, der er større end figurerne) +@msg Opened with {_1} note(s): {_2} Åbnede med {_1} bemærkning(er): {_2} +@msg Opened {url} Åbnede {url} +@msg Outline Stroke: no stroked paths selected Konturér kurve: ingen kurver med kontur er markeret +@msg PDF settings are not an object PDF-indstillinger er ikke et objekt +@msg PDF writer error: {0} PDF-skriverfejl: {0} +@msg Packaged {n} file(s) in {folder}{note} Pakkede {n} fil(er) i {folder}{note} +@msg Packaged {n} file(s){note} Pakkede {n} fil(er){note} +@msg Preview in Browser needs the desktop app (the web downloads files instead) Forhåndsvisning i browser kræver skrivebordsappen (webversionen downloader filer i stedet) +@msg Release Guides: no guides Frigiv hjælpelinjer: ingen hjælpelinjer +@msg Release: no layer to put the rectangle in Frigiv: intet lag at lægge rektanglet i +@msg Replace Spine: select a blend and a path Erstat rygrad: markér en blanding og en kurve +@msg select two or more objects to distribute markér to eller flere objekter, der skal fordeles +@msg select paths or anchor points markér kurver eller ankerpunkter +@msg SVG parse error: {0} SVG-parsningsfejl: {0} +@msg Save for Web: the image is empty Gem til web: billedet er tomt +@msg Save writes {_1}, not {_2}: use document.export for other formats Gem skriver {_1}, ikke {_2}: brug document.export til andre formater +@msg Saved recovery data for {title} Gemte gendannelsesdata for {title} +@msg Saved to User Defined: {_1} Gemt i brugerdefinerede: {_1} +@msg Saved {path} Gemte {path} +@msg Select text or click into text with the Type tool to insert glyphs Markér tekst, eller klik i tekst med tekstværktøjet, for at indsætte glyffer +@msg Shape Builder: no region under the pointer Figurbygger: intet område under markøren +@msg Shape Builder: select paths or compound paths Figurbygger: markér kurver eller sammensatte kurver +@msg Show All Hidden Attributes: nothing is hidden Vis alle skjulte attributter: intet er skjult +@msg Split Into Grid: select paths large enough for the grid Opdel i gitter: markér kurver, der er store nok til gitteret +@msg Targa files are RGB: colorModel rgb Targa-filer er RGB: colorModel rgb +@msg Text Wrap: select a wrap object Tekstombrydning: markér et ombrydningsobjekt +@msg `aspect` must be a number (%) `aspect` skal være et tal (%) +@msg `at` must be [x, y] `at` skal være [x, y] +@msg `color` must be a colour `color` skal være en farve +@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` skal være en kommando, hvis menupunkt åbner en dialogboks (se ui.menuDialog) +@msg `freeform.points` must be an array `freeform.points` skal være et array +@msg `freeform.lines` must be an array `freeform.lines` skal være et array +@msg `gradient` must be an object `gradient` skal være et objekt +@msg `library` is a library id or name (see {list}) `library` er et biblioteks-id eller -navn (se {list}) +@msg `mode` applies to freeform gradients `mode` gælder for freeform-gradienter +@msg `sampleSize` must be 1, 3 or 5 `sampleSize` skal være 1, 3 eller 5 +@msg `stops` must be an array `stops` skal være et array +@msg `svg` must be an object of SVG options (see document.formats) `svg` skal være et objekt med SVG-indstillinger (se document.formats) +@msg `{_1}` is no save command `{_1}` er ikke en gemmekommando +@msg `{area}` is no recovery area name `{area}` er ikke et navn på et gendannelsesområde +@msg `{color}` is not a colour (#rrggbb or a preset name) `{color}` er ikke en farve (#rrggbb eller et forudindstillingsnavn) +@msg `{id}` has no envelope dialog `{id}` har ingen konvolutdialog +@msg `{keep}` is not a side of this axis `{keep}` er ikke en side af denne akse +@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` skal være `auto` eller en sprogkode som `en` eller `zh-hant` +@msg `{key}` must be a #rrggbb colour `{key}` skal være en #rrggbb-farve +@msg `{key}` must be a number `{key}` skal være et tal +@msg `{key}` must be a string `{key}` skal være en streng +@msg `{key}` must be a whole number `{key}` skal være et heltal +@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 `{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 +@msg `{name}` is a built-in workspace; choose another name `{name}` er et indbygget arbejdsområde; vælg et andet navn +@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` er en procesfarve: nuancer gælder for globale og spotfarver +@msg `{name}` is no recovery entry name `{name}` er ikke et navn på en gendannelsespost +@msg `{name}` isn't a colour swatch `{name}` er ikke en farveprøve +@msg `{path}` must be a bool or an object of bools `{path}` skal være en bool eller et objekt med bool-værdier +@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` er ikke en farve (#rrggbb eller et forudindstillingsnavn) +@msg `{tool}` isn't a Liquify tool `{tool}` er ikke et Liquidgør-værktøj +@msg `{tool}` isn't a freehand tool `{tool}` er ikke et frihåndsværktøj +@msg `{to}` is not an available name `{to}` er ikke et tilgængeligt navn +@msg a PostScript error ({name} in `{op}`) en PostScript-fejl ({name} i `{op}`) +@msg a PostScript error ({name} in `{op}`), in {procs} en PostScript-fejl ({name} i `{op}`), i {procs} +@msg a PostScript error ({name} in `{op}`: {at}) en PostScript-fejl ({name} i `{op}`: {at}) +@msg a PostScript error ({name} in `{op}`: {at}), in {procs} en PostScript-fejl ({name} i `{op}`: {at}), i {procs} +@msg a PostScript error ({name}) en PostScript-fejl ({name}) +@msg a bitmap is empty en bitmap er tom +@msg a bitmap is too large to read en bitmap er for stor til at læse +@msg a colour index is 0 to {_1}, not {n} et farveindeks er fra 0 til {_1}, ikke {n} +@msg a colour is an index, #rrggbb or a preset name, not {v} en farve er et indeks, #rrggbb eller et forudindstillingsnavn, ikke {v} +@msg a document needs at least one artboard et dokument skal have mindst én tegneflade +@msg a document needs at least one layer et dokument skal have mindst ét lag +@msg a freeform gradient keeps at least one point en freeform-gradient bevarer mindst ét punkt +@msg a gradient needs at least two stops en gradient skal have mindst to stop +@msg a graphic style named `{to}` already exists der findes allerede en grafikstil med navnet `{to}` +@msg a page is missing en side mangler +@msg a page out of range en side uden for området +@msg a page tree kid isn't a reference et barn i sidetræet er ikke en reference +@msg a page tree node has no kids en node i sidetræet har ingen børn +@msg a page's placement is not finite en sides placering er ikke endelig +@msg a preset named `{_1}` exists der findes en forudindstilling med navnet `{_1}` +@msg a slice size must be a positive number of points en udsnitsstørrelse skal være et positivt antal punkter +@msg a stream runs past the end en strøm løber forbi slutningen +@msg a stream's length is wrong længden på en strøm er forkert +@msg a stream's length isn't a number længden på en strøm er ikke et tal +@msg add a format to export tilføj et format, der skal eksporteres +@msg already editing a pattern der redigeres allerede et mønster +@msg an object can't be read et objekt kan ikke læses +@msg an object in perspective would leave the edited grid et objekt i perspektiv ville forlade det redigerede gitter +@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art eller type +@msg artboard {0} does not exist tegneflade {0} findes ikke +@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard ved denne skala er tegningen {w} × {h} pt, mere end lærredet rummer: vælg en mindre skala, eller tilpas den til tegnefladen +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` aksen skal være "vertical", "horizontal" eller en vinkel i grader, ikke `{v}` +@msg background {other}: transparent, white, black or a colour such as "#ff8800" baggrund {other}: gennemsigtig, hvid, sort eller en farve som "#ff8800" +@msg bad color {v} ugyldig farve {v} +@msg bad colour `{hex}` (#rrggbb) ugyldig farve `{hex}` (#rrggbb) +@msg bad page tree ugyldigt sidetræ +@msg bad shortcut ugyldig genvej +@msg bad stream length ugyldig strømlængde +@msg bad trailer ugyldig trailer +@msg balance must be between 0 and 100 balance skal være mellem 0 og 100 +@msg batch step {_1} (`{id}`) failed: {e} batchtrin {_1} (`{id}`) mislykkedes: {e} +@msg brightness must be dark|mediumDark|mediumLight|light lysstyrken skal være dark|mediumDark|mediumLight|light +@msg can't encode the document: {0} kan ikke kode dokumentet: {0} +@msg can't parse shortcut `{c}` kan ikke parse genvejen `{c}` +@msg can't parse shortcut `{s}` for `{k}` kan ikke parse genvejen `{s}` for `{k}` +@msg can't save version {0}: VectorCraft writes versions {MIN_VERSION} to {VERSION} kan ikke gemme version {0}: VectorCraft skriver versionerne {MIN_VERSION} til {VERSION} +@msg can't write .{ext} images kan ikke skrive .{ext}-billeder +@msg cannot distort a zero-size bounding box kan ikke forvrænge en omsluttende ramme uden størrelse +@msg cannot read PDF: {0} kan ikke læse PDF: {0} +@msg cannot split an open path at its end point kan ikke opdele en åben kurve i dens endepunkt +@msg closing needs three anchors lukning kræver tre ankerpunkter +@msg check an asset to export afkryds et aktiv, der skal eksporteres +@msg choice must be "merge" or "add", not `{choice}` valget skal være "merge" eller "add", ikke `{choice}` +@msg choose a direction to divide in vælg en retning at opdele i +@msg choose a folder to export to vælg en mappe at eksportere til +@msg choose a location for the package vælg en placering til pakken +@msg clipboard is empty udklipsholderen er tom +@msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` er ikke en farve som "#ff8800" +@msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: højst {MAX_TABLE} farver +@msg command `{0}` is not available right now: {1} kommandoen `{0}` er ikke tilgængelig lige nu: {1} +@msg compound paths can only contain paths sammensatte kurver kan kun indeholde kurver +@msg compressed (RLE) bitmaps can't be read yet komprimerede (RLE) bitmaps kan endnu ikke læses +@msg damaged beskadiget +@msg decimals must be {_1}–{_2} (got {_3}) decimaler skal være {_1}–{_2} (fik {_3}) +@msg divide into 1 to {MAX_PIECES} slices opdel i 1 til {MAX_PIECES} udsnit +@msg effect {k} is `{_1}`, not `{id}` effekten {k} er `{_1}`, ikke `{id}` +@msg empty brush stroke tomt penselstrøg +@msg empty cutter tom skæring +@msg empty path tom kurve +@msg file version {0} is newer than this VectorCraft supports ({VERSION}) filversion {0} er nyere, end denne VectorCraft understøtter ({VERSION}) +@msg freeform point {i} needs `at` [x, y] and a valid `color` freeform-punkt {i} skal have `at` [x, y] og en gyldig `color` +@msg freeform line {i} must be an array of point indices freeform-linje {i} skal være et array af punktindekser +@msg give both `start` and `end` [x, y] (or neither) angiv både `start` og `end` [x, y] (eller ingen af dem) +@msg give corners of the path: anchors where its sides meet at an angle angiv kurvens hjørner: ankerpunkter, hvor dens sider mødes i en vinkel +@msg give horizontal or vertical angiv vandret eller lodret +@msg give the path `id` and the width point `index` angiv `id` for kurven og `index` for breddepunktet +@msg guides are locked hjælpelinjerne er låst +@msg image '{key}' has no pixels and was left out billedet '{key}' har ingen pixels og blev udeladt +@msg image data `{_1}` is missing billeddata `{_1}` mangler +@msg image would be too large; lower the resolution billedet ville blive for stort; sænk opløsningen +@msg index must be a number or first, previous, next or last indeks skal være et tal eller first, previous, next eller last +@msg index must be a number or first, previous, next or last, not `{other}` indeks skal være et tal eller first, previous, next eller last, ikke `{other}` +@msg installing from a path is not available on the web; pass the module as `dataBase64` installation fra en sti er ikke tilgængelig på webbet; overfør modulet som `dataBase64` +@msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) intern fejl i `{cmd}`: {msg} (dokumentet blev efterladt, som det var tidligere; meld venligst denne fejl) +@msg internal error: {msg} (please report this bug) intern fejl: {msg} (meld venligst denne fejl) +@msg invalid PDF setting: {0} ugyldig PDF-indstilling: {0} +@msg invalid image data for `{0}` ugyldige billeddata for `{0}` +@msg invalid parameters for `{cmd}`: {msg} ugyldige parametre for `{cmd}`: {msg} +@msg is not an image VectorCraft reads er ikke et billede, som VectorCraft læser +@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know den bruger `{at}`, som VectorCrafts PostScript-læser ikke kender +@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know, in {procs} den bruger `{at}`, som VectorCrafts PostScript-læser ikke kender, i {procs} +@msg its Windows metafile preview was opened instead, which may simplify the art dens Windows-metafil-forhåndsvisning blev åbnet i stedet, hvilket kan forenkle grafikken +@msg its preview was placed instead, at screen resolution dens forhåndsvisning blev placeret i stedet, i skærmopløsning +@msg join needs open paths sammenføjning kræver åbne kurver +@msg lang must be one of {_1} lang skal være en af {_1} +@msg layers can't be wrap objects lag kan ikke være ombrydningsobjekter +@msg missing `folder` `folder` mangler +@msg missing `id` (see plugin.list) `id` mangler (se plugin.list) +@msg missing `name` `name` mangler +@msg missing `tool` `tool` mangler +@msg missing index indeks mangler +@msg multiple windows land with M11.5 flere vinduer lander med M11.5 +@msg name the file giv filen et navn +@msg no CSS to copy: select objects with CSS of their own ingen CSS at kopiere: markér objekter med deres egen CSS +@msg no Live Paint edge near that point ingen Live Paint-kant i nærheden af det punkt +@msg no Live Paint face at that point ingen Live Paint-flade ved det punkt +@msg no PNG encoder ingen PNG-encoder +@msg no active document intet aktivt dokument +@msg no active perspective plane intet aktivt perspektivplan +@msg no anchor points selected ingen ankerpunkter er markeret +@msg no app to open files here ingen app til at åbne filer her +@msg no artboard ingen tegneflade +@msg no artboard {i} (0-based; the document has {_1}) ingen tegneflade {i} (0-baseret; dokumentet har {_1}) +@msg no artboard {i} (0-based; the document has {count}) ingen tegneflade {i} (0-baseret; dokumentet har {count}) +@msg no artboards named ingen navngivne tegneflader +@msg no artwork ingen grafik +@msg no blend options ingen blandingsindstillinger +@msg no bounds ingen grænser +@msg no brush named `{name}` ingen pensel med navnet `{name}` +@msg no brush selected ingen pensel er markeret +@msg no catalog intet katalog +@msg no changes since the last save ingen ændringer siden sidste gemning +@msg no clock to keep a heartbeat by intet ur at holde hjerteslag efter +@msg no colour group `{g}` ingen farvegruppe `{g}` +@msg no cross-reference table ingen krydsreferencetabel +@msg no current layer intet aktuelt lag +@msg no data ingen data +@msg no dialog open ingen åben dialogboks +@msg no document intet dokument +@msg no document open intet åbent dokument +@msg no effect applied yet ingen effekt er anvendt endnu +@msg no effect at index {k} ingen effekt ved indeks {k} +@msg no end record ingen slutpost +@msg no file manager here ingen filhåndtering her +@msg no file reader ingen fillæser +@msg no file system here intet filsystem her +@msg no file writer ingen filskriver +@msg no files to place ingen filer at placere +@msg no format to export to intet format at eksportere til +@msg no free recovery area intet ledigt gendannelsesområde +@msg no gradient stop or freeform point is selected intet gradientstop eller freeform-punkt er markeret +@msg no graphic style `{name}` ingen grafisk stil `{name}` +@msg no graphic style `{n}` ingen grafisk stil `{n}` +@msg no interaction in progress ingen interaktion i gang +@msg no key object intet nøgleobjekt +@msg no library `{key}` (see {list}) intet bibliotek `{key}` (se {list}) +@msg no native format intet indbygget format +@msg no pages ingen sider +@msg no pattern `{name}` intet mønster `{name}` +@msg no perspective grid preset named `{name}` ingen perspektivgitterforudindstilling med navnet `{name}` +@msg no plug-in {0} is installed plug-in'et {0} er ikke installeret +@msg no plug-in {id} is installed plug-in'et {id} er ikke installeret +@msg no point {i} (the gradient has {_1}) intet punkt {i} (gradienten har {_1}) +@msg no preset editor ingen forudindstillingseditor +@msg no previous transform ingen tidligere transformering +@msg no print preset named `{base}` ingen printforudindstilling med navnet `{base}` +@msg no print preset named `{name}` ingen printforudindstilling med navnet `{name}` +@msg no print service ingen printservice +@msg no revert command ingen gendannelseskommando +@msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) ingen gemt PDF-forudindstilling med navnet `{name}` (indbyggede forudindstillinger er skrivebeskyttede: opret en ny ud fra dem) +@msg no saved presets ingen gemte forudindstillinger +@msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) ingen gemt printforudindstilling med navnet `{name}` ({DEFAULT_PRESET} er beskyttet: opret en ny ud fra den) +@msg no saved profiles ingen gemte profiler +@msg no saved selections ingen gemte markeringer +@msg no saved themes ingen gemte temaer +@msg no segment {segment} on line {line} intet segment {segment} på linje {line} +@msg no selected slice in the image: select slices, or save all of them intet markeret udsnit i billedet: markér udsnit, eller gem dem alle +@msg no selection bounds ingen grænser for markeringen +@msg no slice selected intet udsnit markeret +@msg no such action ingen sådan handling +@msg no such anchor intet sådant anker +@msg no such artboard intet sådant artboard +@msg no such document intet sådant dokument +@msg no such layer or object intet sådant lag eller objekt +@msg no such object {0} objektet {0} findes ikke +@msg no such perspective grid preset ingen sådan perspektivgitterforudindstilling +@msg no such recent file ingen sådan seneste fil +@msg no such recent font ingen sådan seneste skrifttype +@msg no such saved selection ingen sådan gemt markering +@msg no such segment intet sådant segment +@msg no such subpath ingen sådan delsti +@msg no such user library intet sådant brugerbibliotek +@msg no such view ingen sådan visning +@msg no such width point intet sådant breddepunkt +@msg no swatch `{name}` ingen farveprøve `{name}` +@msg no swatch library `{l}` (see swatch.library.list) intet farveprøvebibliotek `{l}` (se swatch.library.list) +@msg no symbol instances selected ingen symbolforekomster markeret +@msg no target object has an opacity mask: select one or give `id` intet målobjekt har en opacitetsmaske: markér ét, eller angiv `id` +@msg no threaded text selected ingen sammenkædet tekst markeret +@msg no user library folder here ingen mappe til brugerbiblioteker her +@msg no user workspace `{from}` intet brugerarbejdsområde `{from}` +@msg node {_1} is not text knuden {_1} er ikke tekst +@msg not a DXF drawing ikke en DXF-tegning +@msg not a VectorCraft file: {0} ikke en VectorCraft-fil: {0} +@msg not a VectorCraft shortcut set (missing `overrides`) ikke et VectorCraft-genvejssæt (`overrides` mangler) +@msg not a graphic style library (format `{_1}`) ikke et bibliotek med grafiske stilarter (format `{_1}`) +@msg not a stored central record ikke en gemt central post +@msg not a valid plug-in module: {0} ikke et gyldigt plug-in-modul: {0} +@msg not an EMF or WMF file ikke en EMF- eller WMF-fil +@msg not found ikke fundet +@msg not in pattern editing mode ikke i mønsterredigeringstilstand +@msg nothing encoded intet kodet +@msg nothing selected intet markeret +@msg nothing to align intet at justere +@msg nothing to distort intet at forvrænge +@msg nothing to export: the document has no visible art (or turn on Use Artboards) intet at eksportere: dokumentet har ingen synlig grafik (eller aktivér Brug artboards) +@msg nothing to preview intet at forhåndsvise +@msg nothing to print intet at udskrive +@msg nothing to redo intet at gendanne +@msg nothing to save: the document has no visible art intet at gemme: dokumentet har ingen synlig grafik +@msg nothing to undo intet at fortryde +@msg nothing to warp intet at forvride +@msg nothing to wrap intet at ombryde +@msg object {0} cannot have children objektet {0} kan ikke have underobjekter +@msg object {id} is not a path objektet {id} er ikke en sti +@msg object {id} is not a selected top-level object objektet {id} er ikke et markeret objekt på øverste niveau +@msg page size {w} × {h} sidestørrelse {w} × {h} +@msg page {0} does not exist (the PDF has {1}) siden {0} findes ikke (PDF'en har {1}) +@msg paste unavailable indsætning er ikke tilgængelig +@msg path is already closed stien er allerede lukket +@msg paths contain invalid coordinates stierne indeholder ugyldige koordinater +@msg paths: give at least one file stier: angiv mindst én fil +@msg pattern missing mønster mangler +@msg patterns nested too deeply are filled with mid-grey mønstre, der er indlejret for dybt, udfyldes med mellemgrå +@msg percent must be above 0 and at most 6400, not {p} procent skal være over 0 og højst 6400, ikke {p} +@msg pick a colour, gradient or pattern to save as a swatch vælg en farve, en gradient eller et mønster, der skal gemmes som en farveprøve +@msg plane must be left, right or ground planet skal være venstre, højre eller grund +@msg plug-in ABI: {0} plug-in-ABI: {0} +@msg plug-in exceeded its {0} plug-in'et overskred sin {0} +@msg plug-in failed: {0} plug-in fejlede: {0} +@msg plug-in manifest: {0} plug-in-manifest: {0} +@msg plug-in output: {0} plug-in-output: {0} +@msg plug-in parameters: {0} plug-in-parametre: {0} +@msg plug-in trapped: {0} plug-in'et blev fanget: {0} +@msg print settings are not an object printindstillingerne er ikke et objekt +@msg printing isn't available here udskrivning er ikke tilgængelig her +@msg range `{s}` is empty intervallet `{s}` er tomt +@msg range `{s}`: `{part}` names no number intervallet `{s}`: `{part}` angiver intet tal +@msg range `{s}`: `{part}` runs backwards intervallet `{s}`: `{part}` løber baglæns +@msg range `{s}`: `{t}` is not a number from 1 to {count} intervallet `{s}`: `{t}` er ikke et tal fra 1 til {count} +@msg recovery copy `{file}`: {e} gendannelseskopi `{file}`: {e} +@msg resolutions must be between 1 and 2400 ppi opløsninger skal ligge mellem 1 og 2400 ppi +@msg result would exceed the canvas (coordinates out of range) resultatet ville gå ud over lærredet (koordinater uden for intervallet) +@msg objects can't be nested more than 32 deep objekter kan ikke indlejres mere end 32 niveauer dybt +@msg the SVG's elements are nested more than {0} deep SVG'ens elementer er indlejret mere end {0} niveauer dybt +@msg scale must be a number from {_1} to {_2}, not {_3} skalaen skal være et tal fra {_1} til {_2}, ikke {_3} +@msg scale must be a number from {_1} to {_2}, not {s} skalaen skal være et tal fra {_1} til {_2}, ikke {s} +@msg select (or target) objects whose appearance isn't basic markér (eller målret) objekter, hvis udseende ikke er grundlæggende +@msg select a Live Paint group markér en Live Paint-gruppe +@msg select a blend markér en blanding +@msg select a clip group markér en klipgruppe +@msg select a gradient mesh markér et gradientnet +@msg select a linked image: an embedded one has no file markér et linket billede: et indlejret har ingen fil +@msg select a repeat markér en gentagelse +@msg select a stroke with a variable width markér en streg med variabel bredde +@msg select a symbol instance markér en symbolforekomst +@msg select an Image Trace object markér et Image Trace-objekt +@msg could not decode image: {0} kunne ikke afkode billedet: {0} +@msg image is empty billedet er tomt +@msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) billedet er {width} × {height} pixels, mere end Image Trace håndterer ({max} megapixels) +@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset denne sporing ville skabe flere end {max} ankerpunkter, hvilket er flere, end dokumentet kan vise flydende: hæv Støj, sænk Stier eller Farver, eller vælg en forudindstilling med lavere kvalitet +@msg select an envelope markér en konvolut +@msg select an image markér et billede +@msg select an object in perspective markér et objekt i perspektiv +@msg select artwork (or a colour group) to recolor markér grafik (eller en farvegruppe) for at skifte farver +@msg select at least two objects markér mindst to objekter +@msg select objects, not layers markér objekter, ikke lag +@msg select one path, or give its `id` markér én sti, eller angiv dens `id` +@msg select paths to join markér stier, der skal forbindes +@msg select the art to repeat markér den grafik, der skal gentages +@msg select the artwork to warp markér den grafik, der skal forvrides +@msg select the objects to transform in perspective markér de objekter, der skal transformeres i perspektiv +@msg select two or more graphic styles to merge markér to eller flere grafiske stilarter, der skal flettes +@msg select type in perspective markér tekst i perspektiv +@msg selection has no bounds markeringen har ingen grænser +@msg shadings of an unknown type were left out shadings af en ukendt type blev udeladt +@msg shadings of more than 4096 patches or triangles were left out shadings med flere end 4096 pletter eller trekanter blev udeladt +@msg shadings whose functions are of an unknown type are filled with their middle colour shadings, hvis funktioner er af en ukendt type, udfyldes med deres midterste farve +@msg shortcut for `{k}` must be a string genvejen til `{k}` skal være en streng +@msg stop {i} needs `offset` stop {i} kræver `offset` +@msg stop {i} needs a valid `color` stop {i} kræver en gyldig `color` +@msg stop {i}: `{k}` must be a number stop {i}: `{k}` skal være et tal +@msg the EMF header is damaged EMF-headeren er beskadiget +@msg the PDF has no pages PDF'en har ingen sider +@msg the PDF is no longer loaded PDF'en er ikke længere indlæst +@msg the PDF is password-protected: give its password PDF'en er beskyttet med adgangskode: angiv dens adgangskode +@msg the PDF password is wrong PDF-adgangskoden er forkert +@msg the Save PDF dialog didn't open dialogen Gem PDF blev ikke åbnet +@msg the WMF header is damaged WMF-headeren er beskadiget +@msg the art could not be sampled der kunne ikke tages prøve af grafikken +@msg the art has no bounds grafikken har ingen grænser +@msg the art is too light for any dots grafikken er for lys til nogen prikker +@msg the artboard to fit must be a positive size up to {_1} pt artboardet, der skal tilpasses, skal have en positiv størrelse på op til {_1} pt +@msg the background worker stopped baggrundsprocessen stoppede +@msg the compressed file unpacks to more than {_1} MB den komprimerede fil udpakkes til mere end {_1} MB +@msg the document has never been saved dokumentet er aldrig blevet gemt +@msg the document has no artboard dokumentet har intet artboard +@msg the document has no artboards dokumentet har ingen artboards +@msg the document has no patterns dokumentet har ingen mønstre +@msg the document has no slices dokumentet har ingen udsnit +@msg the document has no symbols dokumentet har ingen symboler +@msg the drawing has no layout `{_1}` (it has {_2}) tegningen har intet layout `{_1}` (den har {_2}) +@msg the drawing is no longer loaded tegningen er ikke længere indlæst +@msg the drawing is too large to open tegningen er for stor til at åbne +@msg the export region is empty eksportområdet er tomt +@msg the export region is not finite eksportområdet er ikke endeligt +@msg the export returned unreadable data eksporten returnerede ulæselige data +@msg the export wrote no file eksporten skrev ingen fil +@msg the file holds only a placeholder page, not its art (it was saved without PDF compatibility): save it again with PDF compatibility on, or as PDF or SVG filen indeholder kun en pladsholderside, ikke dens grafik (den blev gemt uden PDF-kompatibilitet): gem den igen med PDF-kompatibilitet slået til, eller som PDF eller SVG +@msg the file was read up to an error ({why}): what follows it was left out filen blev læst frem til en fejl ({why}): det efterfølgende blev udeladt +@msg the file's preview header points outside the file filens forhåndsvisningsheader peger uden for filen +@msg the image's pixels don't match its size billedets pixels stemmer ikke overens med dets størrelse +@msg the layer has no objects to clip by laget har ingen objekter at klippe efter +@msg the layer or group is locked laget eller gruppen er låst +@msg the guide is on a hidden or locked layer hjælpelinjen ligger på et skjult eller låst lag +@msg the target layer is locked or hidden mållaget er låst eller skjult +@msg the layer to flatten into is hidden laget, der skal flades ud i, er skjult +@msg the object already has an opacity mask objektet har allerede en opacitetsmaske +@msg the object has no geometry objektet har ingen geometri +@msg the object has no opacity mask objektet har ingen opacitetsmaske +@msg the object has no stroke objektet har ingen streg +@msg the object is beyond the plane's horizon objektet er uden for planens horisont +@msg the object isn't in perspective objektet er ikke i perspektiv +@msg the object isn't on a perspective plane objektet er ikke på et perspektivplan +@msg the object would cross the horizon of the perspective plane objektet ville krydse horisonten på perspektivplanet +@msg the objects would leave the grid objekterne ville forlade gitteret +@msg the package came back damaged pakken kom tilbage beskadiget +@msg the pointer is beyond the plane's horizon markøren er uden for planens horisont +@msg the preview image is empty forhåndsvisningsbilledet er tomt +@msg the print job has no data printjobbet har ingen data +@msg the scale is not a usable number skalaen er ikke et brugbart tal +@msg the selected stroke's profile is already listed as {_1} den markerede stregs profil er allerede opført som {_1} +@msg the selection has no URL (or they differ) markeringen har ingen URL (eller de er forskellige) +@msg the selection isn't in perspective markeringen er ikke i perspektiv +@msg the stroke has no weight stregen har ingen tykkelse +@msg the thumbnail image is malformed miniaturebilledet er misdannet +@msg the top object must be a path, compound path or text object to use as a clipping mask det øverste objekt skal være en sti, en sammensat sti eller et tekstobjekt for at kunne bruges som klipmaske +@msg the {tool} tool has no options værktøjet {tool} har ingen indstillinger +@msg there are no objects to release der er ingen objekter at frigive +@msg there are no printer settings to open here der er ingen printerindstillinger at åbne her +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it denne PostScript-fil kan ikke læses ({why}): gem den som PDF eller SVG i det program, der oprettede den +@msg this file's PostScript couldn't be read ({why}): {what} filens PostScript kunne ikke læses ({why}): {what} +@msg this format holds one artboard: export several as PDF or with document.exportForScreens dette format rummer ét artboard: eksportér flere som PDF eller med document.exportForScreens +@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that dette er en DWG-tegning, som ikke kan læses: gem den fra CAD-programmet som en ASCII DXF, og åbn den +@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG dette er en PostScript-fil (en .ai gemt i et ældre format eller uden PDF-kompatibilitet, eller en EPS-fil), som endnu ikke kan åbnes: gem den som PDF, PDF-kompatibel .ai eller SVG +@msg this is not a PostScript file dette er ikke en PostScript-fil +@msg tool shortcuts must be a single key (optionally with Shift) værktøjsgenveje skal være en enkelt tast (eventuelt med Skift) +@msg truncated afkortet +@msg unknown command `{0}` ukendt kommando `{0}` +@msg unknown effect `{id}` ukendt effekt `{id}` +@msg unknown format ukendt format +@msg unknown format `{f}` (Save writes {_1}) ukendt format `{f}` (Gem skriver {_1}) +@msg unknown freeform mode `{m}` (points, lines) ukendt freeform-tilstand `{m}` (punkter, linjer) +@msg unknown gradient kind `{k}` (linear, radial, freeform) ukendt gradienttype `{k}` (lineær, radial, freeform) +@msg unknown highlight `{h}` ukendt fremhævning `{h}` +@msg unknown option `{path}.{k}` ukendt indstilling `{path}.{k}` +@msg unknown overprints `{o}` (preserve, simulate or discard) ukendt overprint `{o}` (bevar, simulér eller forkast) +@msg unknown panel `{raw}` ukendt panel `{raw}` +@msg unknown preference `{key}` ukendt præference `{key}` +@msg unknown preset (one of {_1}) ukendt forudindstilling (én af {_1}) +@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) ukendt forudindstilling `{name}` (høj, mellem, lav eller en gemt: se flattener.presets.list) +@msg unknown preset `{name}` (see flattener.presets.list) ukendt forudindstilling `{name}` (se flattener.presets.list) +@msg unknown preset `{name}` (see webExport.presets.list) ukendt forudindstilling `{name}` (se webExport.presets.list) +@msg unknown side `{other}` (left|right|top|bottom) ukendt side `{other}` (venstre|højre|top|bund) +@msg unknown tool `{tool}` ukendt værktøj `{tool}` +@msg unknown tool `{t}` ukendt værktøj `{t}` +@msg unknown tool or command `{key}` ukendt værktøj eller kommando `{key}` +@msg unknown workspace (one of {_1}) ukendt arbejdsområde (ét af {_1}) +@msg unsupported: {0} ikke understøttet: {0} +@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files filer i version {0} kan ikke komprimeres: kun version {COMPRESSED_SINCE} og senere kan åbne komprimerede filer +@msg width and height are at least 1 pixel bredde og højde er mindst 1 pixel +@msg workspace name can't be empty arbejdsområdets navn må ikke være tomt +@msg {C}: no effect at index {from} {C}: ingen effekt ved indeks {from} +@msg {_1} failed: {e} {_1} mislykkedes: {e} +@msg {_1} files can be opened but not written (see document.formats) {_1}-filer kan åbnes, men ikke skrives (se document.formats) +@msg {_1} font families available {_1} skrifttypefamilier tilgængelige +@msg {_1} is a live effect (Effect › Plug-ins) {_1} er en live-effekt (Effekt › Plug-ins) +@msg {_1} is already used by {_2} {_1} bruges allerede af {_2} +@msg {_1} linked file(s) can't be read here: their previews show {_1} linket(e) fil(er) kan ikke læses her: deres forhåndsvisninger vises +@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} linket(e) billede(r) er ændret på disken: Opdatér links viser de nye versioner +@msg {_1}: not saved, so not closed {_1}: ikke gemt, derfor ikke lukket +@msg {bpp}-bit bitmaps can't be read {bpp}-bit bitmaps kan ikke læses +@msg {command} didn't create an object {command} oprettede ikke et objekt +@msg {label} produced an empty result {label} gav et tomt resultat +@msg {label}: lost track of a shape's style {label}: mistede styr på en forms stil +@msg {label}: select paths {label}: markér stier +@msg {label}: select paths, compound paths, groups or text {label}: markér stier, sammensatte stier, grupper eller tekst +@msg {label}… {label}… +@msg {name}: CRC mismatch {name}: CRC matcher ikke +@msg {name}: no local header {name}: ingen lokal header +@msg {name}: no such recovery entry {name}: gendannelsesposten findes ikke +@msg {path} can't be found: relink it first {path} blev ikke fundet: link den igen først +@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: intet filsystem her (giv dataBase64 for at åbne; udelad stien for at få dataBase64) +@msg {path}: not a file {path}: ikke en fil +@msg {path}: {e} {path}: {e} +@msg {status} ({e}) {status} ({e}) +@msg Couldn't place {name}: {e} Kunne ikke placere {name}: {e} +@msg Couldn't paste from the clipboard: {e} Kunne ikke indsætte fra udklipsholderen: {e} +@msg Saved {path} with {_1} note(s): {first} Gemte {path} med {_1} note(r): {first} +@msg Exported {what} with {_1} note(s): {first} Eksporterede {what} med {_1} note(r): {first} +@msg Exported {what} Eksporterede {what} +@msg Saved {n} files beside {path} Gemte {n} filer ved siden af {path} +@msg Saving Gemmer +@msg Saving {_1} Gemmer {_1} +@msg Exporting {_1} Eksporterer {_1} +@msg Saving recovery data for {_1} Gemmer gendannelsesdata for {_1} +@msg Discarded {_1} recovered document(s) Forkastede {_1} gendannet(e) dokument(er) +@msg Restored {_1} recovered document(s): save them to keep them Genvandt {_1} gendannet(e) dokument(er): gem dem for at beholde dem +@msg Embedded {n} image(s); {missing} missing: relink them first Indlejrede {n} billede(r); {missing} mangler: link dem igen først +@msg Embedded {n} image(s) Indlejrede {n} billede(r) +@msg Relinked {_1} image(s) Linkede {_1} billede(r) igen +@msg Relinked {n} image(s) Linkede {n} billede(r) igen +@msg Relinked {n} image(s); not in that folder: {_1} Linkede {n} billede(r) igen; ikke i den mappe: {_1} +@msg Updated {_1} image(s) Opdaterede {_1} billede(r) +@msg Unembedded to {_1} Fjernede indlejring til {_1} +@msg Reset {_1} Nulstillede {_1} +@msg Sent to {_1} Sendte til {_1} +@msg {_1} files {_1}-filer +@msg The graphics were lost and have been restarted Grafikken gik tabt og er blevet genstartet +@msg The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary} Den grafikprocessor, der blev prøvet først, kunne ikke vise vinduet, så VectorCraft startede igen med {summary} +@msg Loading VectorCraft… Indlæser VectorCraft… +@msg VectorCraft failed to start: {e} VectorCraft kunne ikke starte: {e} +@msg A browser with WebGPU or WebGL2 is required. Der kræves en browser med WebGPU eller WebGL2. +@msg The graphics were lost and couldn't be restarted. Grafikken gik tabt og kunne ikke genstartes. +@msg Your unsaved changes are kept in this browser: reload the page to get them back. Dine ikke-gemte ændringer bevares i denne browser: genindlæs siden for at få dem tilbage. +@msg Reload the page to go on (changes not saved yet may be lost). Genindlæs siden for at fortsætte (ændringer, der endnu ikke er gemt, kan gå tabt). +@msg tool (a tool id) is required tool (et værktøj-id) er påkrævet +@msg {tool} isn't in a toolbar group of several tools {tool} er ikke i en værktøjsgruppe med flere værktøjer +@msg onto must name a panel floating in another group onto skal angive et panel, der flyder i en anden gruppe +@msg below and above must name a panel floating in another group below og above skal angive et panel, der flyder i en anden gruppe +@msg give only one of onto, below and above angiv kun én af onto, below og above +@msg column must be true or a number from 1 column skal være true eller et tal fra 1 +@msg x and y must both be numbers x og y skal begge være tal +@msg "{0}" is not a colour: write it as #rrggbb "{0}" er ikke en farve: skriv den som #rrggbb +@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead dette er ikke et logo med flade farver ({_1}% af de ensfarvede områder matcher nogle få flade farver), så tilstanden Fladt logo ville omdanne dets gradienter til pletter: brug tilstanden Farve i stedet +@msg `{what}` closes nothing `{what}` lukker intet +@msg `{what}` closes something it didn't open `{what}` lukker noget, den ikke åbnede +@msg a path has too many points en sti har for mange punkter +@msg it ends inside a layer den slutter inde i et lag +@msg it has art outside its layers den har grafik uden for sine lag +@msg it has more than {MAX_NODES} objects den har flere end {MAX_NODES} objekter +@msg it has no layers den har ingen lag +@msg it has too many values in a row den har for mange værdier i træk +@msg it has {_1}, which VectorCraft doesn't read from it yet den har {_1}, hvilket VectorCraft endnu ikke læser fra den +@msg its objects are nested too deeply dens objekter er indlejret for dybt +@msg its section ending with `%{end}` doesn't end dens sektion, der ender med `%{end}`, ender ikke +@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekter med flere fyld eller streger, effekter eller pensler kom ind, som de er tegnet ({_1}): deres udseende er endnu ikke redigerbart +@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradienten »{name}« er ikke defineret i filen: der bruges en flad farve i stedet +@msg an image VectorCraft can't read was left out et billede, som VectorCraft ikke kan læse, blev udeladt +@msg an image whose data is cut short was left out et billede, hvis data er afkortet, blev udeladt +@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel linkede billeder viser den gemte forhåndsvisning i lav opløsning, indtil deres filer findes: link dem igen i Links-panelet +@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise rammetekst i rammer, der er for lave til en tekstlinje i den skrifttype, den viser i, fik højere rammer ({_1}): teksten ville ellers ikke blive vist +@msg its editing data doesn't say where its art is dens redigeringsdata siger ikke, hvor dens grafik er +@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) denne fil blev gemt uden PDF-kompatibilitet, og dens redigeringsdata kan ikke læses ({why}) +@msg No fonts are missing in this document Ingen skrifttyper mangler i dette dokument +@msg VectorCraft doesn't search other apps' or the system's folders: {path} VectorCraft gennemsøger ikke andre programmers eller systemets mapper: {path} +@msg VectorCraft doesn't search the Shared or Public folder in Users: {path} VectorCraft gennemsøger ikke mappen Shared eller Public i Users: {path} +@msg {path} is not a folder {path} er ikke en mappe +@msg the search couldn't start: try again in a moment søgningen kunne ikke starte: prøv igen om et øjeblik +@msg searching a folder isn't available on the web mappesøgning er ikke tilgængelig i webversionen +@msg every search thread stopped on an internal error (please report this bug) alle søgetråde stoppede ved en intern fejl (rapportér venligst denne programfejl) +@msg VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it VectorCrafts Fonts-mappe er ikke angivet her: skrivebordsprogrammet og vectorcraft-cli angiver den +@msg choose the font files to add vælg de skrifttypefiler, der skal tilføjes +@msg {n} font file(s) are now in {folder} {n} skrifttypefil(er) er nu i {folder} +@msg {n} font file(s) are now in {folder}; {failed} couldn't be added: {reason} {n} skrifttypefil(er) er nu i {folder}; {failed} kunne ikke tilføjes: {reason} +@msg {n} font(s) aren't available here: their text shows in another font {n} skrifttype(r) er ikke tilgængelig(e) her: teksten vises i en anden skrifttype +@msg not a file ikke en fil +@msg not a font file ikke en skrifttypefil +@msg that document is no longer open dokumentet er ikke længere åbent +@msg larger than 256 MB større end 256 MB +@msg the files come to more than 1 GB: add fewer at a time filerne løber op i mere end 1 GB: tilføj færre ad gangen +@msg every numbered name for it is taken in the Fonts folder alle nummererede navne til den er optaget i Fonts-mappen +@msg every numbered name for {name} is taken in the document's folder alle nummererede navne til {name} er optaget i dokumentets mappe +@msg No linked files to look for Ingen linkede filer at lede efter +@msg Relinked {n} image(s); {name} couldn't be relinked: {reason} Linkede {n} billede(r) igen; {name} kunne ikke linkes igen: {reason} +@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` er trinreferencen {r}, som kun `run` og `command_batch` fortolker +@msg `ids` must be an array of object ids, not {v} `ids` skal være et array af objekt-id'er, ikke {v} +@msg `corners` must be an array of anchor indices, not {v} `corners` skal være et array af ankerindekser, ikke {v} +@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: resultatet af trin {i} har ingen sådan værdi (skriv $$ for at få et bogstaveligt $) +@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: der findes intet trin {i} før dette (trin nummereres fra 0; skriv $$ for at få et bogstaveligt $) +@msg object ids are non-negative integers, not {v} objekt-id'er er ikke-negative heltal, ikke {v} +@msg automation filesystem access is not granted: read authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: læserettighed mangler: {path} +@msg automation filesystem access is not granted: write authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: skriverettighed mangler: {path} +@msg automation path rejected: outside the read root {root}: {path} automatiseringssti afvist: uden for læseroden {root}: {path} +@msg automation path rejected: outside the write root {root}: {path} automatiseringssti afvist: uden for skriveroden {root}: {path} +@msg automation path rejected: path is empty automatiseringssti afvist: stien er tom +@msg automation path rejected: a link that leads nowhere: {path} automatiseringssti afvist: et link, der ikke fører nogen steder hen: {path} +@msg automation path rejected: cannot resolve {path}: {e} automatiseringssti afvist: kan ikke finde frem til {path}: {e} +@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} automatiseringssti afvist: bevægelse til overordnet mappe under en mappe, der ikke findes: {path} +@msg automation path rejected: reserved device names are not allowed: {path} automatiseringssti afvist: reserverede enhedsnavne er ikke tilladt: {path} +@msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode Multichannel-PSD-filer kan ikke læses: gem den i RGB-, CMYK- eller gråtonetilstand +@msg not a PSD file ikke en PSD-fil +@msg the PSD file has an unknown colour mode ({m}) PSD-filen har en ukendt farvetilstand ({m}) +@msg the PSD file's image data uses an unknown compression ({m}) PSD-filens billeddata bruger en ukendt komprimering ({m}) +@msg the PSD image is too large to read ({_1} × {_2} pixels) PSD-billedet er for stort til at læse ({_1} × {_2} pixels) +@msg zipped 32-bit PSD image data with prediction can't be read zippet 32-bit PSD-billeddata med prædiktion kan ikke læses +@msg {depth}-bit PSD files in this colour mode can't be read {depth}-bit PSD-filer i denne farvetilstand kan ikke læses +@msg the PSD file is cut short PSD-filen er afkortet +@msg the PSD file's header is damaged PSD-filens header er beskadiget +@msg the PSD file's image data can't be read: {e} PSD-filens billeddata kan ikke læses: {e} +@msg Imported library {name} Importerede biblioteket {name} +@msg Invalid panel layout; restored the default dock Ugyldigt panellayout; gendannede standarddokken +@msg action is required action er påkrævet +@msg anchor must name a registered panel anchor skal angive et registreret panel +@msg before must name a panel before skal angive et panel +@msg docking action contains an unknown panel or an excessive split path dokningshandlingen indeholder et ukendt panel eller en overdreven opdelingssti +@msg group must be true or false group skal være true eller false +@msg move requires a destination panel move kræver et destinationspanel +@msg move requires an existing destination panel move kræver et eksisterende destinationspanel +@msg onto must name a floating panel onto skal angive et flydende panel +@msg panel group is empty panelgruppen er tom +@msg panel must name a registered panel panel skal angive et registreret panel +@msg saved panel layout is invalid; reset the workspace first det gemte panellayout er ugyldigt; nulstil arbejdsområdet først +@msg zone must be a string zone skal være en streng +@msg zone must be tab, center, left, right, top or bottom zone skal være tab, center, left, right, top eller bottom +@msg {key} must be a finite coordinate {key} skal være en endelig koordinat +@msg x and y must be provided together x og y skal angives sammen +@msg invalid restored panel group ugyldig gendannet panelgruppe +@msg invalid native panel set ugyldigt nativt panelsæt +@msg missing panel layout panellayout mangler +@msg unsupported native docking operation ikke understøttet nativ dokningshandling +@msg this kind of brush has no options dialog yet denne slags pensel har endnu ingen indstillingsdialog + Ruler Origin Linealens nulpunkt + Reset Ruler Origin Nulstil linealens nulpunkt + Column Width: Kolonnebredde: + Import Data… Importér data… + Number of Decimals: Antal decimaler: + Switch x/y Skift x/y + Transpose Transponér + Phone 393×852 Telefon 393×852 + Phone 430×932 Telefon 430×932 + Phone 360×800 Telefon 360×800 + Phone 412×915 Telefon 412×915 + Phone 375×667 Telefon 375×667 + Tablet 768×1024 Tablet 768×1024 + Tablet 820×1180 Tablet 820×1180 + Tablet 834×1194 Tablet 834×1194 + Tablet 1024×1366 Tablet 1024×1366 + Tablet 800×1280 Tablet 800×1280 + Watch 184×224 Ur 184×224 + Watch 198×242 Ur 198×242 + Web 1600×900 Web 1600×900 + Web 1440×900 Web 1440×900 + Web 1366×768 Web 1366×768 + Web 1280×800 Web 1280×800 + Web 1024×768 Web 1024×768 + Legal Legal + Tabloid Tabloid + A3 A3 + A4 A4 + A5 A5 + B4 B4 + B5 B5 + HDTV 1080p HDTV 1080p + HDTV 720p HDTV 720p + 4K UHD 4K UHD + 8K UHD 8K UHD + 2K Cinema 2K Cinema + 4K Cinema 4K Cinema + Greeting Card Hilsenekort + Square 8 × 8 in Kvadrat 8 × 8 tommer + Poster 18 × 24 in Plakat 18 × 24 tommer + Poster 24 × 36 in Plakat 24 × 36 tommer + Logo 1000×1000 Logo 1000×1000 + Business Card 3.5 × 2 in Visitkort 3,5 × 2 tommer + Business Card 85 × 55 mm Visitkort 85 × 55 mm + Letterhead Letter Brevpapir Letter + Letterhead A4 Brevpapir A4 + Envelope DL Konvolut DL + Envelope #10 Konvolut #10 + Social Portrait Post 1080×1350 Socialt portrætopslag 1080×1350 + Social Landscape Post 1200×630 Socialt landskabsopslag 1200×630 + Social Story 1080×1920 Socialt story-format 1080×1920 + Social Tall Post 1000×1500 Socialt opslag i højformat 1000×1500 + Profile Cover 1500×500 Profilbanner 1500×500 + Page Cover 1640×624 Sidebanner 1640×624 + Video Banner 2560×1440 Videobanner 2560×1440 + Video Thumbnail 1280×720 Videominiaturebillede 1280×720 + Keyboard Shortcuts Tastaturgenveje + Set: Sæt: + Reset to Defaults Nulstil til standard + Tool Værktøj + Command Kommando + Shortcut Genvej + Menu Commands Menukommandoer + Press keys… Tryk på taster… + Use Default Brug standard + Go to Conflict Gå til konflikt + Remove the shortcut Fjern genvejen + Click, then press the new shortcut (Esc cancels) Klik, og tryk derefter på den nye genvej (Esc annullerer) + All shortcuts reset to defaults. Alle genveje er nulstillet til standard. + Imported. Press OK to keep the imported set. Importeret. Tryk på OK for at beholde det importerede sæt. + ⚠ {chord} was already used by {names} — it has been removed there. Advarsel: {chord} blev allerede brugt af {names} — den er blevet fjernet der. + VectorCraft Defaults VectorCraft-standard +@plural {n} shortcut is assigned more than once|{n} shortcuts are assigned more than once {n} genvej er tildelt mere end én gang|{n} genveje er tildelt mere end én gang + Simulate prints overprints as Overprint Preview shows them; separations always keep them. A preset flattens transparency before printing (PostScript files always flatten it). Simulér udskriver overprint, som de vises i Overprint-forhåndsvisningen; separationer bevarer dem altid. En forudindstilling flader gennemsigtighed ud før udskrivning (PostScript-filer flader den altid ud). + Preserve Bevar + Simulate Simulér + Portrait (flipped) Portræt (vendt) + Landscape (flipped) Landskab (vendt) + Visible & Printable Layers Synlige & udskrivbare lag + Visible Layers Synlige lag + All Layers Alle lag + Do Not Scale Skalér ikke + Fit to Page Tilpas til siden + Custom Scale Brugerdefineret skalering + Tile Imageable Areas Fliselæg udskrivbare områder + Composite Samlet + Separations Separationer + Up (right reading) Op (rigtig læseretning) + Down (right reading) Ned (rigtig læseretning) + Positive Positiv + Negative Negativ + Subset Delmængde + Complete Fuldstændig + [Custom] [Brugerdefineret] + [Default] [Standard] + No downsampling Ingen nedsampling + Subsample Delsampling + Bicubic Bikubisk + Minimum Minimum + Run Length Run Length + Roman Roman + No conversion Ingen konvertering + Convert to destination Konvertér til destination + Convert to destination (preserve numbers) Konvertér til destination (bevar talværdier) + Don't include profiles Medtag ikke profiler + Include all profiles Medtag alle profiler + Include destination profile Medtag destinationsprofil + Include tagged source profiles Medtag mærkede kildeprofiler + Low resolution (150 ppi) Lav opløsning (150 ppi) + High resolution Høj opløsning + Inserting, deleting and rotating pages Indsættelse, sletning og rotation af sider + Filling in form fields and signing Udfyldning af formularfelter og signering + Commenting, filling in form fields and signing Kommentering, udfyldning af formularfelter og signering + Any except extracting pages Alt undtagen udtrækning af sider + Bounding Box Afgrænsningsboks + Mobile Mobil + Film & Video Film & video + Art & Illustration Kunst & illustration + Branding Branding + Social Social + Triangle Trekant + Triangle (open) Trekant (åben) + Circle (open) Cirkel (åben) + Square (open) Kvadrat (åben) + Bar Bjælke + Arrow Pil + Arrow (open) Pil (åben) + Barbed Modhager + Half Arrow (left) Halvpil (venstre) + Half Arrow (right) Halvpil (højre) + Concave Konkav + Double Bar Dobbeltbjælke + Feather Fjer + Dot on Bar Prik på bjælke + Chevron Chevron + Double Arrow Dobbelpil + Cross Kors + Plus Plus + Hexagon Sekskant + Hexagon (open) Sekskant (åben) + Tag Etiket + Tag (open) Etiket (åben) + Half Circle Halvcirkel + Drop Dråbe + Slash Skråstreg + Double Slash Dobbelt skråstreg + Diamond (open) Diamant (åben) + Reverse Triangle Omvendt trekant + Swallowtail Svalehale + Bracket Klamme + Fork Gaffel + Leaf Blad + Kite Drage + Triangle to Bar Trekant til bjælke + Oval Oval + Complementary Komplementær + Complementary 2 Komplementær 2 + Split Complementary Delt komplementær + Left Complement Venstre komplement + Right Complement Højre komplement + Analogous Analog + Analogous 2 Analog 2 + Monochromatic Monokrom + Shades Nuancer + Triad Triade + Triad 2 Triade 2 + Triad 3 Triade 3 + Tetrad Tetrad + Tetrad 2 Tetrad 2 + Tetrad 3 Tetrad 3 + Compound Sammensat + Compound 2 Sammensat 2 + High Contrast Høj kontrast + High Contrast 2 Høj kontrast 2 + High Contrast 3 Høj kontrast 3 + Pentagram Pentagram + Cool Kold + Warm Varm + Muted Afdæmpet + Vivid Intens + Spectrum Spektrum + Perceptual Perceptuel + Relative Colorimetric Relativ kolorimetrisk + Absolute Colorimetric Absolut kolorimetrisk + Exact Eksakt + Preserve Tints Bevar toninger + Scale Tints Skalér toninger + Linear Lineær + Freeform Freeform + 8 Colors 8 farver + 16 Colors 16 farver + 256 Colors 256 farver + True Colors Truecolor + Preserve Appearance Bevar udseende + Maximize Editability Maksimér redigerbarhed + Clip Group Klipgruppe + Guide Hjælpelinje + Symbol Symbol + Envelope Konvolut + Mesh Net + Placed Document Placeret dokument + Radial Repeat Radial gentagelse + Grid Repeat Gittergentagelse + Mirror Repeat Spejlgentagelse + Auto Size Automatisk størrelse + Shrink Text to Fit Formindsk tekst så den passer + Ascent Overhøjde + Cap Height Versalhøjde + x Height x-højde + Justify Justér + First Baseline Første basislinje + Vertical Align Lodret justering + Save Selection Gem markering + No Legend Ingen forklaring + Standard Legend Standardforklaring + Legends in Wedges Forklaringer i kiler + Ratio Forhold + Even Jævnt + Stacked Stablet + Twirl Hvirvel + Pucker Krymp + Bloat Opsvulm + Scallop Muslingekant + Wrinkle Rynke + Unknown Ukendt + Copyrighted Ophavsretsbeskyttet + Public Domain Public domain + None (Color Preview) Ingen (farveforhåndsvisning) + Rasterized Complex Regions Rastrerede komplekse områder + Transparent Objects Gennemsigtige objekter + All Affected Objects Alle berørte objekter + Expanded Patterns Udvidede mønstre + Outlined Strokes Streger omsat til kurver + Outlined Text Tekst omsat til kurver + All Rasterized Regions Alle rastrerede områder + High Resolution Høj opløsning + [High Resolution] [Høj opløsning] + Medium Resolution Middelopløsning + [Medium Resolution] [Middelopløsning] + Low Resolution Lav opløsning + [Low Resolution] [Lav opløsning] + Copyright and Contact Info Ophavsret og kontaktoplysninger + All Slices Alle udsnit + Selected Slices Markerede udsnit + No Slices Ingen udsnit + Images Only Kun billeder + HTML and Images HTML og billeder + Selective Selektiv + Adaptive Adaptiv + Black & White Sort & hvid + No Dither Ingen dithering + Diffusion Diffusion + Unsorted Usorteret + Sort by Hue Sortér efter farvetone + Sort by Luminance Sortér efter lysstyrke + Sort by Popularity Sortér efter hyppighed + Baseline (Standard) Baseline (standard) + Baseline Optimized Baseline optimeret + Art Optimized Optimeret til grafik + Type Optimized Optimeret til tekst + Language Level 2 Sprogniveau 2 + Language Level 3 Sprogniveau 3 + TIFF (Black & White) TIFF (sort & hvid) + TIFF (8-bit Color) TIFF (8-bit farve) + 1 bit 1 bit + 4 bit 4 bit + 8 bit 8 bit + 16 bit 16 bit + 24 bit 24 bit + 32 bit 32 bit + 16 bits/pixel 16 bits/pixel + 24 bits/pixel 24 bits/pixel + 32 bits/pixel 32 bits/pixel + DWG (use DXF) DWG (brug DXF) + Artistic Kunstnerisk + Film Grain… Filmkorn… + Highlight Area Fremhævet område +file-filter All readable files Alle læsbare filer +file-filter All placeable files Alle placerbare filer +file-filter Swatch libraries Farveprøvebiblioteker +file-filter Flattener presets Flattener-forudindstillinger +file-filter PDF presets PDF-forudindstillinger +file-filter Print presets Printforudindstillinger +file-filter SVG Compressed Komprimeret SVG +file-filter PDF-compatible .ai PDF-kompatibel .ai +file-filter VectorCraft Template VectorCraft-skabelon +file-filter Templates Skabeloner +diagnostics render Gengivelse +diagnostics ui Brugerflade + Preset {number} Forudindstilling {number} + PRs PR'er + @{login}: {prs}, {commits}, +{added} / −{deleted} lines (Δ {delta}), +{binary_added} / −{binary_deleted} binary assets, {first} – {last} @{login}: {prs}, {commits}, +{added} / −{deleted} linjer (Δ {delta}), +{binary_added} / −{binary_deleted} binære filer, {first} – {last} +@plural {n} PR|{n} PRs {n} PR|{n} PR'er +@plural {n} commit|{n} commits {n} commit|{n} commits +gradient-kind Radial Radial +mark-style Japanese Japansk +@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) den komprimerede fil udpakkes til mere end VectorCraft læser ({_1} MB dokument, {_2} MB pr. billede) +@msg the document is too large to save compressed ({0}): save it with Use Compression off dokumentet er for stort til at gemme komprimeret ({0}): gem det med Brug komprimering slået fra +@msg the file stopped being read (please report this bug) læsningen af filen stoppede (rapportér venligst denne programfejl) + Concentric Dividers: Koncentriske skillelinjer: + Radial Dividers: Radiale skillelinjer: + Skew: Skævhed: + Inside Indvendig + Outside Udvendig + Use Outside Rectangle As Frame Brug det ydre rektangel som ramme + Create Compound Path From Ellipses Opret sammensat sti af ellipser + Fill Grid Udfyld gitter + Select two or more objects, or a group Markér to eller flere objekter eller en gruppe + Back Bagfra + Both sides Begge sider + Classic Klassisk + Cove Cove + Depth Dybde + Front Forfra + Isometric Bottom Isometrisk bund + Isometric Left Isometrisk venstre + Isometric Right Isometrisk højre + Isometric Top Isometrisk top + Off-Axis Back Ude af akse bagfra + Off-Axis Bottom Ude af akse bund + Off-Axis Front Ude af akse forfra + Off-Axis Left Ude af akse venstre + Off-Axis Right Ude af akse højre + Off-Axis Top Ude af akse top + Ogee Ogee + Reset all 3D settings to their defaults. Nulstil alle 3D-indstillinger til standard. + Step Trin + Taper Aftapering + Twist Vridning + Volume Rumfang +3d Cap Kappe +3d Solid Massiv +3d Hollow Udhulet +3d Bevel Facet +3d Rounded Afrundet +3d Space Rum + Bevel Shape Facetform + Bevel Inside Indvendig facet + Bevel both sides Facet på begge sider + Presets Forudindstillinger +@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Et objekt kan kun have én 3D-effekt. Redigér den eksisterende effekts indstillinger, eller fjern den først. +@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} kræver i øjeblikket en sti eller en sammensat sti. Konvertér tekst, billeder og grupper til stier først. +@msg {label} requires a shape with at least two anchors {label} kræver en form med mindst to ankerpunkter +@msg The shape is too complex for a 3D effect Formen er for kompleks til en 3D-effekt +@msg The 3D surface is too complex; simplify the shape 3D-fladen er for kompleks; forenkl formen + Transform Objects Transformér objekter +@msg unknown parameter `{key}`; did you mean `{near}`? ukendt parameter `{key}`; mente du `{near}`? +@msg unknown parameter `{key}`; the command takes no parameters ukendt parameter `{key}`; kommandoen tager ingen parametre +@msg unknown parameter `{key}`; the command takes: {keys} ukendt parameter `{key}`; kommandoen tager: {keys} + Load EPS into Clipboard Indlæs EPS i udklipsholderen +@msg an image of {w} × {h} pixels is too large to draw et billede på {w} × {h} pixels er for stort til at tegne + Fill and Stroke Controls Styring af fyld og streg + Coloring Controls Styring af farvelægning + Draw Mode Controls Styring af tegnetilstand + Screen Mode Controls Styring af skærmtilstand + Drawer menu Tegnermenu + Toolbar: Show Controls Værktøjslinje: Vis kontroller +@msg control must be fillStroke, coloring, drawMode or screenMode control skal være fillStroke, coloring, drawMode eller screenMode +@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode ukendt kontrolelement `{name}`: fillStroke, coloring, drawMode eller screenMode +@msg give a fraction `t` from 0 to 1 angiv en brøkdel `t` fra 0 til 1 +@msg give the path `id` angiv stiens `id` +@msg invalid stroke widths ugyldige stregtykkelser +@msg object has no weighted stroke objektet har ingen streg med variabel bredde + Single Corner Radius Enkelt hjørneradius + Top Left: Øverst til venstre: + Top Right: Øverst til højre: + Bottom Left: Nederst til venstre: + Bottom Right: Nederst til højre: + Softness Blødhed + Ambient Light Omgivelseslys + Light behind the object Lys bag objektet + 2up 2 pr. ark + 4up 4 pr. ark + A6 A6 + Convert to Global Konvertér til global + Grey Grå + Text Displayed in Cell Tekst vist i celle + Working RGB Arbejds-RGB + Art and Bright Colors Kunst og klare farver + Dot Gain 15% Punktvækst 15% + Expand 1 Selected Udvid 1 markeret + Expand Object Udvid objekt + Gray Gamma 2.2 Grå gamma 2,2 + Phone 375×812 Telefon 375×812 + Photo Low Fidelity Foto i lav kvalitet + Print & Proof Print & prøvetryk + Printing & Proofing Udskrivning & prøvetrykning + Restricted Colors Begrænsede farver + Simplified Chinese Forenklet kinesisk + Tablet 1024×768 Tablet 1024×768 + Video 1920×1080 Video 1920×1080 + Video 3840×2160 Video 3840×2160 + Web 1280×720 Web 1280×720 +@msg give corners of the path: anchors between two straight sides angiv stiens hjørner: ankerpunkter mellem to rette sider + [1P-High View] [1P-høj visning] + [1P-Low View] [1P-lav visning] + [1P-Normal View] [1P-normal visning] + [2P-High View] [2P-høj visning] + [2P-Low View] [2P-lav visning] + [2P-Normal View] [2P-normal visning] + [3P-Low View] [3P-lav visning] + [3P-Normal View] [3P-normal visning] + Automation Automatisering + Essentials Grundlæggende + Essentials Classic Grundlæggende klassisk + Layout Layout + OpenType OpenType + Painting Maling + Printing and Proofing Udskrivning og prøvetrykning + Tracing Sporing + Typography Typografi + VectorCraft VectorCraft + Web Web + Decrease Font Size by Five Steps Formindsk skriftstørrelsen med fem trin + Increase Font Size by Five Steps Forøg skriftstørrelsen med fem trin + Intertwine Sammenflet + Arrange Intertwine Area Arrangér sammenfletningsområde + Rotate View to Selection Rotér visning til markering +@msg select an Intertwine group vælg en sammenfletningsgruppe +@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS dette er en PostScript-fil (en .ai gemt i et ældre format eller uden PDF-kompatibilitet, eller en EPS-fil), ikke en PDF: Filer › Åbn og Filer › Placér læser den som EPS +@msg The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set Den seneste start af VectorCraft endte på {name}, før vinduet blev vist, så programmet startede på {summary}. For at prøve den grafikprocessor igen skal du ændre Indstillinger › Ydeevne › Grafikprocessor eller starte med WGPU_BACKEND angivet +@msg The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set Den seneste start af VectorCraft endte, mens grafikprocessorerne blev listet, så programmet startede på {summary} med kun OpenGL. For at prøve de andre grafikbackends igen skal du ændre Indstillinger › Ydeevne › Grafikprocessor eller starte med WGPU_BACKEND angivet + Loaded Texture Indlæst tekstur + Load Texture… Indlæs tekstur… + Texture Image Teksturbillede +@msg the image can't be read as a texture billedet kan ikke læses som en tekstur + Legacy (version 7) Forældet (version 7) + Transparent Images: Gennemsigtige billeder: + Flatten Fladgør + Omit Udelad + Legacy .ai Options Forældede .ai-indstillinger + A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. En forældet .ai-fil indeholder én kunstplade: hver kunstplade, du markerer, gemmes som en separat fil. + {count} of {total} artboards {count} af {total} kunstplader + Check an artboard to save. Sæt kryds ved en kunstplade for at gemme den. + Hidden Objects: Skjulte objekter: + Hidden Layer Skjult lag + Make Visible Gør synlig + Text: Tekst: + Editable Redigerbar + PDF-compatible PDF-kompatibel + Dimension Mål + Drawing : real size — 1:10 labels 1 unit on the page as 10. Tegning : virkelig størrelse — 1:10 mærker 1 enhed på siden som 10. + Preview (1 inch on the page) Forhåndsvisning (1 tomme på siden) + Set as Default Angiv som standard + out from a highlighted edge to place its dimension | ud fra en fremhævet kant for at placere dens mål | + cancels | Double-click the tool for options annullerer | Dobbeltklik på værktøjet for indstillinger + Angular Vinklet + Away from the Line Væk fra linjen + Between Line and Object Mellem linje og objekt + Drag out from a segment, or click two points, then click to place. Træk ud fra et segment, eller klik to punkter, og klik derefter for at placere. + Drag out from a corner, or click two straight lines, then click to place. Træk ud fra et hjørne, eller klik to rette linjer, og klik derefter for at placere. + Drag out from a circle or arc for its radius, inwards for its diameter. Træk ud fra en cirkel eller en bue for dens radius, indad for dens diameter. +@msg Choose a circle or a perfect arc Vælg en cirkel eller en perfekt bue +@msg Choose a precision from 0 to 6 Vælg en præcision fra 0 til 6 + Create Dimension Opret mål + Dimension Tool Målværktøj + Dimension Tool Options Indstillinger for målværktøj + Display Visning + Font Family: Skriftfamilie: + Font Style: Skriftstil: + Precision: Præcision: + Update Dimensions Opdatér mål +@msg Angles require two straight segments Vinkler kræver to rette segmenter +@msg Choose two different points Vælg to forskellige punkter +@msg Choose two non-parallel straight segments Vælg to ikke-parallelle rette segmenter +@msg Choose two nonzero angle arms Vælg to vinkelben, der ikke er nul +@msg Dimension point is outside the canvas Målpunktet er uden for lærredet +@msg Dimension size is outside its supported range Målstørrelsen er uden for det understøttede interval +@msg First measured segment no longer exists Det første målte segment findes ikke længere +@msg Invalid dimension distance Ugyldig måleafstand +@msg Invalid dimension font name Ugyldigt målskriftnavn +@msg Invalid drawing scale Ugyldig tegningsmålestok +@msg Invalid real scale Ugyldig virkelig målestok +@msg Measured anchor no longer exists Det målte ankerpunkt findes ikke længere +@msg Measured segment no longer exists Det målte segment findes ikke længere +@msg Non-finite dimension Ikke-endeligt mål +@msg Scale must be drawing:real, for example 1:10 Målestokken skal være tegning:virkelig, for eksempel 1:10 +@msg Scale ratio must be between 1:1000000 and 1000000:1 Målestoksforholdet skal være mellem 1:1000000 og 1000000:1 +@msg Second measured segment no longer exists Det andet målte segment findes ikke længere +@msg Unknown dimension mode Ukendt måltilstand +@msg Unknown dimension option {k} Ukendt målindstilling {k} +@msg Unknown dimension style Ukendt målstil +@msg Unknown unit Ukendt enhed + Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Luk de runde ender af åbne profiler. Delvise omdrejninger beholder deres skårne sider åbne. + Darkness Mørke + Isolate Selected Object Isolér markeret objekt + Keep selected Behold markeret + Sample Color from Screen Sample farve fra skærmen + The active document changed while the screen color sampler was open Det aktive dokument ændrede sig, mens skærmfarvesampleren var åben + This system has no screen color sampler Dette system har ingen skærmfarvesampler + Color blindness – Tritanopia-type Farveblindhed – tritanopitype + Panels Except Tools and Control Paneler undtagen værktøjer og kontrol + Reset Tracking and Kerning Nulstil spærring og kerning + Reset Horizontal Scale Nulstil vandret skalering +@msg span must be "object" or "selection", got {v} span skal være "object" eller "selection", fik {v} +@msg span: "selection" places a `gradient`, not a swatch span: "selection" placerer en `gradient`, ikke en farveprøve +@msg span: "selection" places a gradient: give `gradient` span: "selection" placerer en overgang: angiv `gradient` +@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" placerer en lineær eller radial overgang, ikke en fri +@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" placerer overgangen selv: udelad `start` og `end` (de placerer den allerede i dokumentkoordinater) + Make Image Dynamic Gør billede dynamisk + Import Data Sets… Importér datasæt… + Export Data Sets… Eksportér datasæt… + Make Graph Dynamic Gør diagram dynamisk + Batch… Batch… + Source Folder: Kildemappe: + Destination Folder: Destinationsmappe: + Overrides Tilsidesættelser + Uses every data set of the open document. Bruger alle datasæt i det åbne dokument. +@msg Batch: {count} processed Batch: {count} behandlet +@msg Batch: {count} processed, {failed} failed Batch: {count} behandlet, {failed} fejlede +@msg choose a destination folder vælg en destinationsmappe +@msg choose a source folder vælg en kildemappe +@msg open a document with data sets åbn et dokument med datasæt +@msg the action has no steps handlingen har ingen trin + Registration: Passning: + By Layer Efter lag + By Object Efter objekt + Clear All Ryd alle + Filter Filter + Groups Grupper + Hidden Skjulte + Images Billeder + Linked Files Sammenkædede filer + Locked Låste + Color Panel Options Indstillinger for farvepanel + Filter Layers Panel Rows Filtrér rækker i lagpanelet + Hide Template Skjul skabelon + Show Template Vis skabelon + Accent Color Accentfarve + Amber Rav + Coral Koral + Violet Violet + Color Scheme Farveskema + Modern Moderne +@msg `{key}` must be an array of these names: {_1} `{key}` skal være et array af disse navne: {_1} + Mode Tilstand + Blur Mode Slørtilstand + Edge Only Kun kant + Overlay Edge Overlæg kant + Path Length Stilængde +@msg the file's layers weren't read ({_1}): it comes in as its page, in one layer filens lag blev ikke læst ({_1}): den kommer ind som sin side, i ét lag +@msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part filens lag blev ikke læst fra dens redigeringsdata ({_1}): de kommer fra dens PDF-del +@msg `mode` must be grayscale, rgb, hsb, cmyk or websafe `mode` skal være grayscale, rgb, hsb, cmyk eller websafe +@msg the Color Picker is not open farvevælgeren er ikke åben +@msg the screen color sampler is already open skærmfarvesampleren er allerede åben +@msg this system has no screen color sampler dette system har ingen skærmfarvesampler +swatch [None] [Ingen] +swatch [Registration] [Passning] +swatch White Hvid +swatch Black Sort +swatch Red Rød +swatch Orange Red Oranjerød +swatch Orange Orange +swatch Amber Rav +swatch Yellow Gul +swatch Yellow Green Gulgrøn +swatch Lime Lime +swatch Green Grøn +swatch Emerald Smaragd +swatch Teal Petrol +swatch Aqua Aqua +swatch Cyan Green Cyangrøn +swatch Cyan Cyan +swatch Azure Azur +swatch Blue Blå +swatch Indigo Indigo +swatch Violet Violet +swatch Purple Purpur +swatch Magenta Magenta +swatch Rose Rosa +swatch Pink Pink +swatch Brown Brun +swatch Tan Gulbrun +swatch Coffee Kaffe +swatch Chocolate Chokolade +swatch Sand Sand +swatch Copper Kobber +swatch Rust Rust +swatch Walnut Valnød +swatch Espresso Espresso +swatch White, Black Hvid, sort +swatch Radial White, Black Radial hvid, sort +swatch Sunset Solnedgang +swatch Ocean Ocean +swatch Grays Gråtoner +swatch Brights Kraftige farver +swatch Bright Red Kraftig rød +swatch Bright Yellow Kraftig gul +swatch Bright Green Kraftig grøn +swatch Bright Blue Kraftig blå +swatch Bright Violet Kraftig violet + Automate Automatisér + Include All Subdirectories Medtag alle undermapper + Override Action "Open" Commands Tilsidesæt handlingens "Åbn"-kommandoer + Override Action "Save" Commands Tilsidesæt handlingens "Gem"-kommandoer + Override Action "Export" Commands Tilsidesæt handlingens "Eksportér"-kommandoer + Stop on Errors Stop ved fejl + Save and Close Gem og luk + Play: Afspil: + +# Strings main added after this catalog was written + Convert to Straight Lines Konvertér til lige linjer + Corner Point Angle Threshold: Vinkeltærskel for hjørnepunkter: + A File per Object Én fil pr. objekt + Example: {name} Eksempel: {name} + Export Preset: Eksportforudindstilling: + Folders: Mapper: + Insert Indsæt + Nothing is selected. Select the art to export, then open this tab again. Intet er markeret. Markér grafikken, der skal eksporteres, og åbn derefter fanen igen. + One File of the Selection Én fil af markeringen + Start at: Start ved: + {n} objects selected {n} objekter markeret + Delete Export Preset Slet eksportforudindstilling + Export Presets Eksportforudindstillinger + Save Export Preset Gem eksportforudindstilling + Export Layer Eksportér lag + Export Selection Eksportér markering + All in One Folder Alt i én mappe + By Size or Row Efter størrelse eller række + By Format Efter format + By Artboard or Item Efter kunstplade eller element + By Item, Then Format Efter element, derefter format + Document Name Dokumentnavn + Artboard or Item Name Navn på kunstplade eller element + Number (####) Nummer (####) + Date (YYYYMMDD) Dato (YYYYMMDD) + Date (YYYY-MM-DD) Dato (YYYY-MM-DD) + Time (hhmmss) Tid (hhmmss) + PDF Print Tryk-PDF + Words Longer Than: Ord længere end: + After First: Efter første: + Before Last: Før sidste: + Hyphen Limit: Bindestregsgrænse: + Hyphenation Zone: Orddelingszone: + letters bogstaver + hyphens bindestreger + Hyphenate Capitalized Words Orddel ord med stort begyndelsesbogstav + Outline Object Objekt til kontur + Update Selection Opdater markering + Show Original Path Vis oprindelig kurve +@msg check a layer to export markér et lag, der skal eksporteres +@msg select the art to export first markér først grafikken, der skal eksporteres +@msg no saved selection recalled ingen gemt markering hentet + Merge only with selection Flet kun med markering + Sketch Skitse + Halftone Pattern… Rastermønster… + Halftone Pattern Rastermønster + Pattern Type Mønstertype + Dot Prik + Export Type: Eksporttype: + Movie Clip Filmklip +@msg name is empty navnet er tomt +@msg no symbol named `{n}` intet symbol med navnet `{n}` diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv new file mode 100644 index 000000000..47d8f29e7 --- /dev/null +++ b/crates/ui-egui/src/i18n/id.tsv @@ -0,0 +1,5035 @@ +# Indonesian (id) catalog. Same format as zh-hant.tsv: context source translation +# (empty context = a plain UI string; `@id` = a command id; `@plural` = a single form, the count reading the same) +# +# Clean-room: written from the meaning of VectorCraft's English labels in neutral, international +# Indonesian, using the feature names Indonesian speakers of vector illustration software know. No other +# localisation was copied. Labels use sentence case (only the first word capitalised). + New Design Desain baru + Delete Design Hapus desain + Paste Design Tempel desain + Select Unused Seleksi yang tidak digunakan + Glowing Edges… Tepi berpendar… + About VectorCraft Tentang VectorCraft + Align to Path Sejajarkan ke jalur + Cut Path at Selected Anchor Points Potong jalur pada titik jangkar terseleksi + Join Our Discord Gabung ke Discord kami + Language Bahasa + UI Brightness Kecerahan UI + Dark Gelap + Medium Dark Agak gelap + Medium Light Agak terang + Light Terang + Quit VectorCraft Keluar dari VectorCraft + File Berkas + New… Baru… + New from Template… Baru dari templat… + Open… Buka… + Open Recent Files Buka berkas terbaru + Clear Recent Files Kosongkan berkas terbaru + Show in Folder Tampilkan di folder + Close Tutup +@id file.close berkas.tutup + Close All Tutup semua + Save Simpan + Save As… Simpan sebagai… + Save a Copy… Simpan salinan… + Save as Template… Simpan sebagai templat… + Save as PDF… Simpan sebagai PDF… + Save for Office Documents… Simpan untuk dokumen Office… + Revert Kembalikan + Place… Tempatkan… + Export Ekspor + Export for Screens… Ekspor untuk layar… + Export As… Ekspor sebagai… + Export As SVG… Ekspor sebagai SVG… + Export As PNG… Ekspor sebagai PNG… + Save for Web (Legacy)… Simpan untuk web (lama)… + Export Selection… Ekspor seleksi… + Package… Paketkan… + Scripts Skrip + Check Script… Periksa skrip… + Check Script Periksa skrip + Other Script… Skrip lain… + Document Setup… Pengaturan dokumen… + Document Color Mode Mode warna dokumen + CMYK Color Warna CMYK + RGB Color Warna RGB + File Info… Info berkas… + Print… Cetak… + Edit Edit + Undo Urungkan + Redo Ulangi + Cut Potong + Copy Salin + Paste Tempel + Paste in Front Tempel di depan + Paste in Back Tempel di belakang + Paste in Place Tempel di tempat semula + Paste on All Artboards Tempel di semua artboard + Paste without Formatting Tempel tanpa pemformatan + Clear Hapus + Find and Replace… Cari dan ganti… + Find Next Cari berikutnya + Spelling Ejaan + Auto Spell Check Pemeriksaan ejaan otomatis + Check Spelling… Periksa ejaan… + Edit Custom Dictionary… Edit kamus kustom… + Edit Colors Edit warna + Recolor Artwork… Warnai ulang karya seni… + Recolor with Preset Warnai ulang dengan preset + 1 Color Job… Pekerjaan 1 warna… + 2 Color Job… Pekerjaan 2 warna… + 3 Color Job… Pekerjaan 3 warna… + Color Library… Pustaka warna… + Adjust Color Balance… Sesuaikan keseimbangan warna… + Blend Front to Back Blend depan ke belakang + Blend Horizontally Blend horizontal + Blend Vertically Blend vertikal + Convert to CMYK Konversi ke CMYK + Convert to Grayscale Konversi ke skala kelabu + Convert to RGB Konversi ke RGB + Invert Colors Balikkan warna + Overprint Black… Overprint hitam… + Saturate… Saturasi… + Edit Original Edit asli + Transparency Flattener Presets… Preset perata transparansi… + Print Presets… Preset cetak… + PDF Presets… Preset PDF… + Perspective Grid Presets… Preset kisi perspektif… + Color Settings… Pengaturan warna… + Assign Profile… Tetapkan profil… + Keyboard Shortcuts… Pintasan papan tik… + Import Shortcut Set Impor set pintasan + Import Shortcut Set… Impor set pintasan… + Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. Setiap entri dalam berkas dicocokkan dengan alat, perintah, atau panel VectorCraft. Periksa kecocokannya dan jawab sisanya. + Keys Tombol + The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. Berkas menulis tombol dan pengubah sebagai kode. Periksa makna yang diusulkan dan jawab kode yang belum diketahui. + Click, then press the key this code stands for (Esc cancels) Klik, lalu tekan tombol yang diwakili kode ini (Esc membatalkan) + Press a key… Tekan tombol… + from your earlier imports dari impor Anda sebelumnya + from the format's public description dari deskripsi publik format tersebut + not known yet belum diketahui + Shortcuts Pintasan + In the File Dalam berkas + In VectorCraft Dalam VectorCraft + Needs Attention Only Perlu perhatian saja + Ready Siap + Needs a choice Perlu pilihan + Needs a key Perlu tombol + No equivalent Tidak ada padanan + Tools take a single key (optionally with Shift) Alat menggunakan satu tombol (opsional dengan Shift) + A later row takes this shortcut Baris selanjutnya menggunakan pintasan ini + Same item as a later row Item yang sama dengan baris selanjutnya + Start from VectorCraft Defaults Mulai dari default VectorCraft + Add to the Current Shortcuts Tambahkan ke pintasan saat ini + Remember My Choices Ingat pilihan saya + {ready} of {total} ready {ready} dari {total} siap + The file is too large to read in full, so only its first part is listed. Berkas terlalu besar untuk dibaca sepenuhnya, sehingga hanya bagian pertamanya yang tercantum. + Report copied Laporan disalin + Line {line} Baris {line} + Line {line}: {name}: {status} Baris {line}: {name}: {status} + Read Shortcut Set File Baca berkas set pintasan + Import Matches Impor kecocokan + Remember Import Matches Ingat kecocokan impor + Forget Import Matches Lupakan kecocokan impor +@plural agrees with {n} default shortcut|agrees with {n} default shortcuts sesuai dengan {n} pintasan default +@plural {n} part of the file wasn't read|{n} parts of the file weren't read {n} bagian berkas tidak dibaca +@msg This shortcut file is in a form VectorCraft can't read. Berkas pintasan ini dalam bentuk yang tidak dapat dibaca oleh VectorCraft. +@msg not a shortcut set: the text doesn't start with a slash name and an opening brace bukan set pintasan: teks tidak diawali dengan nama garis miring dan kurung kurawal buka + Preferences… Preferensi… + Object Objek + Transform Transformasi + Transform Again Ulangi transformasi + Move… Pindahkan… + Rotate… Putar… + Reflect… Cermin… + Scale… Skala… + Shear… Miringkan… + Transform Each… Transformasi masing-masing… + Reset Bounding Box Atur ulang kotak pembatas + Arrange Susun + Bring to Front Bawa ke depan + Bring Forward Majukan + Send Backward Mundurkan + Send to Back Kirim ke belakang + Send to Current Layer Kirim ke lapisan saat ini + Align Sejajarkan + Horizontal Align Left Sejajarkan kiri horizontal + Horizontal Align Center Sejajarkan tengah horizontal + Horizontal Align Right Sejajarkan kanan horizontal + Vertical Align Top Sejajarkan atas vertikal + Vertical Align Center Sejajarkan tengah vertikal + Horizontal & Vertical Align Center Sejajarkan tengah horizontal & vertikal + Vertical Align Bottom Sejajarkan bawah vertikal + Group Grupkan + Ungroup Buka grup + Lock Kunci + Selection Seleksi + All Artwork Above Semua karya seni di atas + Other Layers Lapisan lain + Unlock All Buka kunci semua + Hide Sembunyikan + Show All Tampilkan semua + Expand… Perluas… + Expand Appearance Perluas tampilan + Crop Image Pangkas gambar + Mask Masker + Rasterize… Rasterisasi… + Create Gradient Mesh… Buat mesh gradien… + Create Object Mosaic… Buat mozaik objek… + Vector Halftone… Halftone vektor… + Create Trim Marks Buat tanda potong + Flatten Transparency… Ratakan transparansi… + Make Pixel Perfect Buat piksel sempurna + Slice Slice + Make Buat + Make and Expand Buat dan perluas + Release Lepaskan + Create from Guides Buat dari panduan + Create from Selection Buat dari seleksi + Duplicate Slice Duplikatkan slice + Combine Slices Gabungkan slice + Divide Slices… Bagi slice… + Delete All Hapus semua + Slice Options… Opsi slice… + Clip to Artboard Potong ke artboard + Path Jalur + Join Sambungkan + Average… Rata-rata… + Outline Stroke Garis luar stroke + Offset Path… Jalur ofset… + Reverse Path Direction Balikkan arah jalur + Simplify… Sederhanakan… + Add Anchor Points Tambahkan titik jangkar + Remove Anchor Points Hapus titik jangkar + Divide Objects Below Bagi objek di bawah + Split Into Grid… Bagi menjadi kisi… + Clean Up… Bersihkan… + Shape Bentuk + Convert to Shape Konversi ke bentuk + Expand Shape Perluas bentuk + Pattern Pola + Edit Pattern Edit pola + Tile Edge Color… Warna tepi ubin… + Repeat Perulangan + Radial Radial + Grid Kisi + Mirror Cermin + Options… Opsi… + Blend Blend + Blend Options… Opsi blend… + Expand Perluas + Replace Spine Ganti spine + Reverse Spine Balikkan spine + Reverse Front to Back Balikkan depan ke belakang + Envelope Distort Distorsi amplop + Make with Warp… Buat dengan warp… + Reset with Warp… Atur ulang dengan warp… + Make with Mesh… Buat dengan mesh… + Reset with Mesh… Atur ulang dengan mesh… + Make with Top Object Buat dengan objek teratas + Envelope Options… Opsi amplop… + Edit Contents Edit konten + Edit Envelope Edit amplop + Perspective Perspektif + Attach to Active Plane Kaitkan ke bidang aktif + Release with Perspective Lepaskan dengan perspektif + Move Plane to Match Object Pindahkan bidang agar sesuai objek + Edit Text Edit teks + Live Paint Live Paint + Merge Gabungkan + Gap Options… Opsi celah… + Image Trace Image Trace + Image Trace (Make and Expand) Image Trace (buat dan perluas) + Release Image Trace Lepaskan Image Trace + Text Wrap Bungkus teks + Make Text Wrap Buat bungkus teks + Release Text Wrap Lepaskan bungkus teks + Text Wrap Options… Opsi bungkus teks… + Clipping Mask Masker kliping + Edit Clipping Path Edit jalur kliping + Compound Path Jalur majemuk + Artboards Artboard + Convert to Artboards Konversi ke artboard + Rearrange All Artboards… Atur ulang semua artboard… + Fit to Artwork Bounds Sesuaikan ke batas karya seni + Fit to Selected Art Sesuaikan ke karya seni terseleksi + Graph Grafik + Type… Jenis… + Data… Data… + Design… Desain… + Graph Design Desain grafik + Graph Marker Penanda grafik + Column… Kolom… + Marker… Penanda… + Collect for Export Kumpulkan untuk ekspor + As Single Asset Sebagai aset tunggal + As Multiple Assets Sebagai beberapa aset + Plug-ins Plug-in + No filter plug-ins installed Tidak ada plug-in filter yang terinstal + Install Plug-in… Instal plug-in… + Reload Plug-ins Muat ulang plug-in + Type Teks + Font Font + Recent Fonts Font terbaru + Size Ukuran + Glyphs Glif + Insert Special Character Sisipkan karakter khusus + Bullet Poin + Copyright Symbol Simbol hak cipta + Ellipsis Elipsis + Paragraph Symbol Simbol paragraf + Registered Trademark Symbol Simbol merek terdaftar + Section Symbol Simbol bagian + Trademark Symbol Simbol merek dagang + Em Dash Tanda pisah em + En Dash Tanda pisah en + Discretionary Hyphen Tanda hubung diskresioner + Nonbreaking Hyphen Tanda hubung tanpa putus + Double Left Quotation Marks Tanda kutip ganda kiri + Double Right Quotation Marks Tanda kutip ganda kanan + Single Left Quotation Mark Tanda kutip tunggal kiri + Single Right Quotation Mark Tanda kutip tunggal kanan + Insert Whitespace Character Sisipkan karakter spasi + Em Space Spasi em + En Space Spasi en + Hair Space Spasi rambut + Sixth Space Spasi seperenam + Thin Space Spasi tipis + Nonbreaking Space Spasi tanpa putus + Figure Space Spasi angka + Punctuation Space Spasi tanda baca + Third Space Spasi sepertiga + Quarter Space Spasi seperempat + Insert Break Character Sisipkan karakter putus + Forced Line Break Putus baris paksa + Tab Tab + Convert To Area Type Konversi ke teks area + Convert To Point Type Konversi ke teks titik + Area Type Options… Opsi teks area… + Type on a Path Teks pada jalur + Area Type Teks area + Rainbow Pelangi + Skew Miring + 3D Ribbon Pita 3D + Stair Step Undakan tangga + Gravity Gravitasi + Type on a Path Options… Opsi teks pada jalur… + Threaded Text Teks beruntun + Create Buat + Release Selection Lepaskan seleksi + Remove Threading Hapus rangkaian + Fit Headline Sesuaikan judul + Find Font… Cari font… + Change Case Ubah huruf besar-kecil + UPPERCASE HURUF BESAR + lowercase huruf kecil + Title Case Huruf besar di awal kata + Sentence case Huruf besar di awal kalimat + Smart Punctuation… Tanda baca cerdas… + Create Outlines Buat garis luar + Optical Margin Alignment Perataan margin optik + Fill with Placeholder Text Isi dengan teks placeholder + Insert Inline Symbol Sisipkan simbol sebaris + Show Hidden Characters Tampilkan karakter tersembunyi + Bold Tebal + Italic Miring + Hide Hidden Characters Sembunyikan karakter tersembunyi + Type Orientation Orientasi teks + Horizontal Horizontal + Vertical Vertikal + Select Seleksi + All Semua + All on Active Artboard Semua di artboard aktif + Deselect Batalkan seleksi + Reselect Seleksi ulang + Inverse Kebalikan + Next Object Above Objek berikutnya di atas + Next Object Below Objek berikutnya di bawah + Same Sama + Appearance Tampilan + Appearance Attribute Atribut tampilan + Blending Mode Mode blend + Fill & Stroke Isian & stroke + Fill Color Warna isian + Opacity Opasitas + Stroke Color Warna stroke + Stroke Weight Ketebalan stroke + Graphic Style Gaya grafis + Symbol Instance Instans simbol + Font Family Keluarga font + Font Family & Style Keluarga & gaya font + Font Family, Style & Size Keluarga, gaya & ukuran font + Font Size Ukuran font + Text Fill Color Warna isian teks + Text Stroke Color Warna stroke teks + All on Same Layers Semua di lapisan yang sama + Direction Handles Gagang arah + Brush Strokes Sapuan kuas + Bristle Brush Strokes Sapuan kuas bristle + Clipping Masks Masker kliping + Slices Slice + Stray Points Titik lepas + Open Paths Jalur terbuka + All Text Objects Semua objek teks + Point Text Objects Objek teks titik + Area Text Objects Objek teks area + Start Global Edit Mulai edit global + Save Selection… Simpan seleksi… + Edit Selection… Edit seleksi… + Effect Efek + Apply Last Effect Terapkan efek terakhir + Last Effect… Efek terakhir… + Document Raster Effects Settings… Pengaturan efek raster dokumen… + Vector Effects Efek vektor + 3D and Materials 3D dan material + Extrude & Bevel… Ekstrusi & bevel… + Revolve… Revolusi… + Inflate… Gelembungkan… + Materials… Material… + Color Adjustments Penyesuaian warna + Brightness/Contrast… Kecerahan/kontras… + Curves… Kurva… + Hue/Saturation… Rona/saturasi… + Levels… Level… + Shift to Color… Geser ke warna… + Temperature/Tint… Suhu/tint… + Rectangle… Persegi panjang… + Rounded Rectangle… Persegi panjang bulat… + Ellipse… Elips… + Crop Marks Tanda potong + Distort & Transform Distorsi & transformasi + Free Distort… Distorsi bebas… + Pucker & Bloat… Mengkerut & menggembung… + Roughen… Kasarkan… + Transform… Transformasi… + Tweak… Tweak… + Twist… Puntir… + Zig Zag… Zig Zag… + Pathfinder Pathfinder + Add Tambah +@id pathfinder.add pathfinder.tambah + Intersect Iris + Exclude Kecualikan + Subtract Kurangi + Minus Back Kurangi belakang + Divide Bagi + Trim Pangkas + Crop Krop + Outline Garis luar + Stylize Stilisasi + Round Corners… Bulatkan sudut… + Scribble… Coretan… + Drop Shadow… Bayangan jatuh… + Inner Glow… Pendar dalam… + Outer Glow… Pendar luar… + Feather… Feather… + SVG Filters Filter SVG + Apply SVG Filter… Terapkan filter SVG… + Warp Warp + Arc… Lengkung… + Arc Lower… Lengkung bawah… + Arc Upper… Lengkung atas… + Arch… Lengkungan… + Bulge… Membuncit… + Shell Lower… Cangkang bawah… + Shell Upper… Cangkang atas… + Flag… Bendera… + Wave… Gelombang… + Fish… Ikan… + Rise… Naik… + Fisheye… Fisheye… + Squeeze… Remas… + Raster Effects Efek raster + Blur Kabur + Gaussian Blur… Kabur Gaussian… + View Tampilan + Preview Pratinjau + Overprint Preview Pratinjau overprint + Pixel Preview Pratinjau piksel + Trim View Tampilan terpangkas + Presentation Mode Mode presentasi + Screen Mode Mode layar + Normal Screen Mode Mode layar normal + Full Screen Mode with Menu Bar Mode layar penuh dengan bilah menu + Full Screen Mode Mode layar penuh + Proof Setup Pengaturan uji + Working CMYK CMYK kerja + RGB RGB + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + VectorCraft Generic CMYK (SWOP-like) CMYK generik VectorCraft (mirip SWOP) + Legacy Macintosh RGB (Gamma 1.8) RGB Macintosh lama (Gamma 1.8) + Internet Standard RGB (sRGB) RGB standar Internet (sRGB) + Monitor RGB RGB monitor + Color blindness – Protanopia-type Buta warna – tipe protanopia + Color blindness – Deuteranopia-type Buta warna – tipe deuteranopia + Customize… Kustomisasi… + Proof Colors Uji warna + Zoom In Perbesar + Zoom Out Perkecil + Fit Artboard in Window Sesuaikan artboard ke jendela + Fit All in Window Sesuaikan semua ke jendela + Actual Size Ukuran sebenarnya + Reset Rotate View Atur ulang putaran tampilan + Hide Edges Sembunyikan tepi + Show Edges Tampilkan tepi + Hide Artboards Sembunyikan artboard + Show Artboards Tampilkan artboard + Show Print Tiling Tampilkan ubinan cetak + Hide Print Tiling Sembunyikan ubinan cetak + Hide Slices Sembunyikan slice + Show Slices Tampilkan slice + Lock Slices Kunci slice + Rulers Penggaris + Show Rulers Tampilkan penggaris + Hide Rulers Sembunyikan penggaris + Change to Global Rulers Ubah ke penggaris global + Show Video Rulers Tampilkan penggaris video + Hide Bounding Box Sembunyikan kotak pembatas + Show Bounding Box Tampilkan kotak pembatas + Show Transparency Grid Tampilkan kisi transparansi + Hide Transparency Grid Sembunyikan kisi transparansi + Hide Text Threads Sembunyikan utas teks + Show Text Threads Tampilkan utas teks + Hide Gradient Annotator Sembunyikan anotator gradien + Show Gradient Annotator Tampilkan anotator gradien + Hide Corner Widget Sembunyikan widget sudut + Show Corner Widget Tampilkan widget sudut + Guides Panduan + Hide Guides Sembunyikan panduan + Show Guides Tampilkan panduan + Lock Guides Kunci panduan + Unlock Guides Buka kunci panduan + Make Guides Buat panduan + Release Guides Lepaskan panduan + Clear Guides Hapus panduan + Smart Guides Panduan cerdas + Perspective Grid Kisi perspektif + Show Grid Tampilkan kisi + Hide Grid Sembunyikan kisi + Snap to Grid Snap ke kisi + Lock Grid Kunci kisi + Unlock Grid Buka kunci kisi + Lock Station Point Kunci titik pengamat + Define Grid… Tentukan kisi… + One Point Perspective Perspektif satu titik + Two Point Perspective Perspektif dua titik + Three Point Perspective Perspektif tiga titik + Save Grid as Preset… Simpan kisi sebagai preset… + Snap to Pixel Snap ke piksel + Precise Cursors Kursor presisi + Snap to Point Snap ke titik + Snap to Glyph Snap ke glif + New View… Tampilan baru… + Edit Views… Edit tampilan… + Saved View 1 Tampilan tersimpan 1 + Saved View 2 Tampilan tersimpan 2 + Saved View 3 Tampilan tersimpan 3 + Saved View 4 Tampilan tersimpan 4 + Saved View 5 Tampilan tersimpan 5 + Saved View 6 Tampilan tersimpan 6 + Saved View 7 Tampilan tersimpan 7 + Saved View 8 Tampilan tersimpan 8 + Saved View 9 Tampilan tersimpan 9 + Saved View 10 Tampilan tersimpan 10 + Window Jendela + New Window Jendela baru + Cascade Bertingkat + Tile Ubin + Float in Window Mengambang di jendela + Consolidate All Windows Konsolidasikan semua jendela + Workspace Ruang kerja + Reset Essentials Atur ulang Essentials + Reset Automation Atur ulang Automation + Reset Essentials Classic Atur ulang Essentials Classic + Reset Layout Atur ulang Layout + Reset Painting Atur ulang Painting + Reset Printing and Proofing Atur ulang Printing and Proofing + Reset Tracing Atur ulang Tracing + Reset Typography Atur ulang Typography + Reset Web Atur ulang Web + New Workspace… Ruang kerja baru… + Manage Workspaces… Kelola ruang kerja… + Control Kontrol + Contextual Task Bar Bilah tugas kontekstual + Pin Bar Position Sematkan posisi bilah + Reset Bar Position Atur ulang posisi bilah + Tools Alat + Toolbars Bilah alat + Advanced Lanjutan + Double Column Kolom ganda + Actions Tindakan + Asset Export Ekspor aset + Attributes Atribut + Brushes Kuas + Color Warna + Color Guide Panduan warna + Color Themes Tema warna + CSS Properties Properti CSS + Document Info Info dokumen + Flattener Preview Pratinjau perata + Gradient Gradien + Graphic Styles Gaya grafis + Graphic Styles (4) Gaya grafis (4) + History Riwayat + Info Info + Layers Lapisan + 1 Layer 1 lapisan + Libraries Pustaka + Links Tautan + {count} linked image(s) changed on disk. Show the new versions? {count} gambar tertaut di disk telah berubah. Tampilkan versi baru? + Update Changed Links Perbarui tautan yang berubah +@msg Updated {_1} linked file(s) changed on disk Telah memperbarui {_1} berkas tertaut yang berubah di disk + Magic Wand Tongkat ajaib + Navigator Navigator + Pattern Options Opsi pola + Properties Properti + Separations Preview Pratinjau separasi + Stroke Stroke + SVG Interactivity Interaktivitas SVG + Swatches Sampel warna + Symbols Simbol + Transparency Transparansi + Character Karakter + Character Styles Gaya karakter + Paragraph Paragraf + Paragraph Styles Gaya paragraf + Tabs Tab + Variables Variabel + New Variable… Variabel baru… + Unbind Variable Lepaskan ikatan variabel + Delete Variable Hapus variabel + New Data Set… Set data baru… + Edit Data Set… Edit set data… + Delete Data Set Hapus set data + Next Data Set Set data berikutnya + Previous Data Set Set data sebelumnya + Make Text Dynamic Buat teks dinamis + Make Visibility Dynamic Buat visibilitas dinamis + Capture Data Set Tangkap set data + Update Data Set Perbarui set data + Rename Data Set… Ganti nama set data… + Variable Options… Opsi variabel… + Brush Libraries Pustaka kuas + Built-in Libraries Pustaka bawaan + User Defined Ditentukan pengguna + Other Library… Pustaka lain… + Graphic Style Libraries Pustaka gaya grafis + Shadows and Glows Bayangan dan pendar + Outlines and Rules Garis luar dan garis + Hand-Drawn Gambar tangan + Gradient Finishes Finishing gradien + Shape Effects Efek bentuk + Blends and Transparency Blend dan transparansi + Save Graphic Style Library… Simpan pustaka gaya grafis… + Swatch Libraries Pustaka sampel warna + Default Swatches Sampel warna default + Web Safe 216 Web Safe 216 + Grays and Neutrals Kelabu dan netral + Earth Tones Warna tanah + Skin Tone Ramps Gradasi warna kulit + Pastels Pastel + Brights Warna terang + Metallic Gradients Gradien metalik + Perceptual Scales Skala perseptual + Harmony Sets Set harmoni + Gradients Gradien + Spectrum Gradients Gradien spektrum + Sky Gradients Gradien langit + Neutral Gradients Gradien netral + Duotone Gradients Gradien duotone + Radial Glows Pendar radial + Save Swatch Library… Simpan pustaka sampel warna… + Symbol Libraries Pustaka simbol + Help Bantuan + ArtCraft Website Situs web ArtCraft + VectorCraft on getartcraft.com VectorCraft di getartcraft.com + VectorCraft on GitHub VectorCraft di GitHub + Search Commands… Cari perintah… + VectorCraft Help… Bantuan VectorCraft… + Auto Color Warna otomatis + Automatic Otomatis + Isolate Selected Group Isolasi grup terseleksi + Exit Isolation Mode Keluar dari mode isolasi + Make Clipping Mask Buat masker kliping + Release Clipping Mask Lepaskan masker kliping + Make Compound Path Buat jalur majemuk + Release Compound Path Lepaskan jalur majemuk + Select All Seleksi semua + Collapse Panels to Icons Ciutkan panel ke ikon + Collapse to Icons Ciutkan ke ikon + Expand Panels Perluas panel + About Tentang + Contributors Kontributor + Models Model + Username Nama pengguna + Display name Nama tampilan + Real name Nama asli + Sort Urutkan + Reverse the order Balikkan urutan + Grab bag Aneka ragam + Table Tabel + Name (A–Z) Nama (A–Z) + Merged PRs PR digabungkan + Commits Komit + Lines added Baris ditambahkan + Lines deleted Baris dihapus + Line delta Delta baris + Binary assets added Aset biner ditambahkan + Binary assets removed Aset biner dihapus + First commit Komit pertama + Last commit Komit terakhir + First Pertama + Last Terakhir + {people} contributors · {commits} commits {people} kontributor · {commits} komit + No contributor data was built into this copy. Data kontributor tidak disertakan dalam salinan ini. + No model credits were built into this copy. Kredit model tidak disertakan dalam salinan ini. + Company Perusahaan + Version Versi + % of all commits % dari semua komit + Lines +/− Baris +/− + Drag to move Seret untuk memindahkan + Drag to move out of the set Seret untuk memindahkan keluar dari set + Drag to float as a toolbar Seret untuk mengambang sebagai bilah alat + Put back in the toolbar Kembalikan ke bilah alat + Float Panel Mengambangkan panel + Dock Panel Tambatkan panel + Float Tool Group Mengambangkan grup alat + Services Layanan + Hide VectorCraft Sembunyikan VectorCraft + Hide Others Sembunyikan yang lain + Minimize Minimalkan + Zoom Zum + Bring All to Front Bawa semua ke depan + Settings Pengaturan + General Umum + Selection & Anchor Display Seleksi & tampilan jangkar + Units Satuan + Guides & Grid Panduan & kisi + Hyphenation Pemenggalan kata + Performance & Storage Kinerja & penyimpanan + User Interface Antarmuka pengguna + Performance Kinerja + File Handling Penanganan berkas + Clipboard Handling Penanganan papan klip + Appearance of Black Tampilan hitam + Devices Perangkat + A file dialog is open: choose there first Dialog berkas sedang terbuka: pilih di sana terlebih dahulu + The document was closed while its file dialog was open Dokumen ditutup saat dialog berkasnya masih terbuka + The dialog was closed before its file dialog answered Dialog ditutup sebelum dialog berkasnya memberi jawaban + Shaper Shaper + Expand Shaper Group Perluas grup Shaper + Release Shaper Group Lepaskan grup Shaper + Select Shaper Face Seleksi sisi Shaper + Constrain Batasi + Free Transform Transformasi bebas + Perspective Distort Distorsi perspektif + Radial Blur… Kabur radial… + Smart Blur… Kabur cerdas… + Sharpen Pertajam + Unsharp Mask… Unsharp Mask… + Pixelate Pikselasi + Color Halftone… Halftone warna… + Crystallize… Kristalisasi… + Mezzotint… Mezzotint… + Pointillize… Pointillisme… + Video Video + De-Interlace… De-Interlace… + NTSC Colors Warna NTSC + RGB: RGB: + CMYK: CMYK: + CMYK CMYK + Texture Tekstur + Craquelure… Craquelure… + Grain… Grain… + Mosaic Tiles… Ubin mozaik… + Patchwork… Tambal sulam… + Stained Glass… Kaca patri… + Texturizer… Texturizer… + System Title Bar Bilah judul sistem + Exit Keluar + Distort Distorsi + Diffuse Glow… Pendar difusi… + Glass… Kaca… + Ocean Ripple… Riak samudra… + Rename Data Set Ganti nama set data + Rename Variable Ganti nama variabel + Activate Panel Aktifkan panel + Change Panel Layout Ubah tata letak panel + Close Panel Tutup panel + Move Panel Pindahkan panel + Group with Grupkan dengan + Resize panels Ubah ukuran panel + Resize panel window Ubah ukuran jendela panel + Lighting Pencahayaan + Revolve angle Sudut revolusi + Axis side Sisi sumbu + Rotation X Rotasi X + Rotation Y Rotasi Y + Rotation Z Rotasi Z + Shading Pengarsiran + Intensity Intensitas + Segments Segmen + Show axis Tampilkan sumbu + Reset all Atur ulang semua + Reset to default Atur ulang ke default + Reset all Revolve settings to their defaults. Atur ulang semua pengaturan Revolusi ke defaultnya. + Drag values or labels. Double-click a value to type. Seret nilai atau label. Klik dua kali pada nilai untuk mengetik. + Drag rings to rotate. Shift snaps to 15°. Seret cincin untuk memutar. Shift mengunci ke kelipatan 15°. + Drag the light, or its rings. Shift snaps to 15°. Seret cahaya atau cincinnya. Shift mengunci ke kelipatan 15°. +lighting Light Cahaya + Keep visible surfaces only when expanding Pertahankan hanya permukaan yang terlihat saat memperluas + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Pangkas geometri yang tertutup saat Perluas tampilan. Pratinjau langsung mempertahankan permukaan penuh. Cat transparan dan permukaan yang melampaui batas visibilitas mempertahankan semua sisi. + Left Kiri + Right Kanan + Offset Ofset + Rotation Rotasi + Cancel Batal + Accented Edges… Tepi beraksen… + Angled Strokes… Sapuan miring… + Crosshatch… Arsiran silang… + Dark Strokes… Sapuan gelap… + Ink Outlines… Garis luar tinta… + Spatter… Cipratan… + Sprayed Strokes… Sapuan semprot… + Sumi-e… Sumi-e… + Vertical Guide Panduan vertikal + Horizontal Guide Panduan horizontal + Align center Sejajarkan ke tengah + Align left Sejajarkan ke kiri + Align right Sejajarkan ke kanan + Browser Browser + Color Guide Options… Opsi panduan warna… + Color Picker… Pemilih warna… + Coming soon — tracked in the parity plan Segera hadir — dilacak dalam rencana paritas + Command Dialog Dialog perintah + Copy Selected Style Salin gaya terpilih + Corners… Sudut… + Define Perspective Grid… Definisikan kisi perspektif… + Delete Workspace Hapus ruang kerja + Document Raster Effects Settings Dialog Dialog pengaturan efek raster dokumen + Drop Shadow Bayangan jatuh + DXF Options Dialog Dialog opsi DXF + Edit Toolbar Edit bilah alat + Edit Views Edit tampilan + Effect… Efek… + EPS Options Dialog Dialog opsi EPS + Expand Layers Panel Rows Perluas baris panel Lapisan + Export CSS… Ekspor CSS… + Export Keyboard Shortcuts… Ekspor pintasan papan ketik… + File Info Dialog Dialog info file + Go to Artboard Buka artboard + Graphic Style Options… Opsi gaya grafis… + Symbol Options Opsi simbol + Symbol Options… Opsi simbol… + Home Home + Import Keyboard Shortcuts… Impor pintasan papan ketik… + Justify all lines Ratakan semua baris + Justify with last line aligned left Ratakan dengan baris terakhir rata kiri + Keyboard Shortcut Conflicts Konflik pintasan papan ketik + Keyboard Shortcut Set Set pintasan papan ketik + Interface Language Bahasa antarmuka + Last Effect: {name} Efek terakhir: {name} + Last Effect: {} Efek terakhir: {} + Limit Color Guide to Library Batasi Panduan Warna ke pustaka + List Keyboard Shortcuts Daftar pintasan papan ketik + List Workspaces Daftar ruang kerja + Menu Dialog Dialog menu + Merge Graphic Styles… Gabungkan gaya grafis… + New Color Group… Grup warna baru… + New Layer Options… Opsi lapisan baru… + New Swatch… Swatch baru… + No saved views (View → New View…) Tidak ada tampilan tersimpan (Tampilan → Tampilan baru…) + One Point Perspective Preset 1 Preset perspektif satu titik 1 + One Point Perspective Preset 2 Preset perspektif satu titik 2 + One Point Perspective Preset 3 Preset perspektif satu titik 3 + One Point Perspective Preset 4 Preset perspektif satu titik 4 + One Point Perspective Preset 5 Preset perspektif satu titik 5 + Open Recent File 1 Buka file terbaru 1 + Open Recent File 10 Buka file terbaru 10 + Open Recent File 11 Buka file terbaru 11 + Open Recent File 12 Buka file terbaru 12 + Open Recent File 13 Buka file terbaru 13 + Open Recent File 14 Buka file terbaru 14 + Open Recent File 15 Buka file terbaru 15 + Open Recent File 16 Buka file terbaru 16 + Open Recent File 17 Buka file terbaru 17 + Open Recent File 18 Buka file terbaru 18 + Open Recent File 19 Buka file terbaru 19 + Open Recent File 2 Buka file terbaru 2 + Open Recent File 20 Buka file terbaru 20 + Open Recent File 21 Buka file terbaru 21 + Open Recent File 22 Buka file terbaru 22 + Open Recent File 23 Buka file terbaru 23 + Open Recent File 24 Buka file terbaru 24 + Open Recent File 25 Buka file terbaru 25 + Open Recent File 26 Buka file terbaru 26 + Open Recent File 27 Buka file terbaru 27 + Open Recent File 28 Buka file terbaru 28 + Open Recent File 29 Buka file terbaru 29 + Open Recent File 3 Buka file terbaru 3 + Open Recent File 30 Buka file terbaru 30 + Open Recent File 4 Buka file terbaru 4 + Open Recent File 5 Buka file terbaru 5 + Open Recent File 6 Buka file terbaru 6 + Open Recent File 7 Buka file terbaru 7 + Open Recent File 8 Buka file terbaru 8 + Open Recent File 9 Buka file terbaru 9 + Options for Selection… Opsi untuk seleksi… + Panel Options… Opsi panel… + Panels Panel + Perspective Plane Options… Opsi bidang perspektif… + Placement Options… Opsi penempatan… + Plug-in… Plug-in… + Preview in Browser Pratinjau di browser + Printer Setup… Pengaturan printer… + Printers Printer + Recent File File terbaru + Recent Font Font terbaru + Recent Font 1 Font terbaru 1 + Recent Font 10 Font terbaru 10 + Recent Font 11 Font terbaru 11 + Recent Font 12 Font terbaru 12 + Recent Font 13 Font terbaru 13 + Recent Font 14 Font terbaru 14 + Recent Font 15 Font terbaru 15 + Recent Font 2 Font terbaru 2 + Recent Font 3 Font terbaru 3 + Recent Font 4 Font terbaru 4 + Recent Font 5 Font terbaru 5 + Recent Font 6 Font terbaru 6 + Recent Font 7 Font terbaru 7 + Recent Font 8 Font terbaru 8 + Recent Font 9 Font terbaru 9 + Redo {name} Pulihkan {name} + Redo {} Pulihkan {} + Missing Fonts Dialog Dialog font yang hilang + Find Fonts in Folder… Temukan font di folder… + Rename Workspace Ganti nama ruang kerja + Reset Keyboard Shortcuts Setel ulang pintasan papan ketik + Reset {name} Setel ulang {name} + Reset {} Setel ulang {} + Save Document Info… Simpan info dokumen… + Save PDF Dialog Dialog simpan PDF + Saved Selection 1 Seleksi tersimpan 1 + Saved Selection 10 Seleksi tersimpan 10 + Saved Selection 11 Seleksi tersimpan 11 + Saved Selection 12 Seleksi tersimpan 12 + Saved Selection 13 Seleksi tersimpan 13 + Saved Selection 14 Seleksi tersimpan 14 + Saved Selection 15 Seleksi tersimpan 15 + Saved Selection 16 Seleksi tersimpan 16 + Saved Selection 17 Seleksi tersimpan 17 + Saved Selection 18 Seleksi tersimpan 18 + Saved Selection 19 Seleksi tersimpan 19 + Saved Selection 2 Seleksi tersimpan 2 + Saved Selection 20 Seleksi tersimpan 20 + Saved Selection 21 Seleksi tersimpan 21 + Saved Selection 22 Seleksi tersimpan 22 + Saved Selection 23 Seleksi tersimpan 23 + Saved Selection 24 Seleksi tersimpan 24 + Saved Selection 25 Seleksi tersimpan 25 + Saved Selection 3 Seleksi tersimpan 3 + Saved Selection 4 Seleksi tersimpan 4 + Saved Selection 5 Seleksi tersimpan 5 + Saved Selection 6 Seleksi tersimpan 6 + Saved Selection 7 Seleksi tersimpan 7 + Saved Selection 8 Seleksi tersimpan 8 + Saved Selection 9 Seleksi tersimpan 9 + Select Tool Pilih alat + Set Keyboard Shortcut Atur pintasan papan ketik + Set Zoom Atur zoom + Show Package Tampilkan paket + Show Panel Tampilkan panel + Spot Colors… Warna spot… + Swatch Options… Opsi swatch… + Three Point Perspective Preset 1 Preset perspektif tiga titik 1 + Three Point Perspective Preset 2 Preset perspektif tiga titik 2 + Three Point Perspective Preset 3 Preset perspektif tiga titik 3 + Three Point Perspective Preset 4 Preset perspektif tiga titik 4 + Three Point Perspective Preset 5 Preset perspektif tiga titik 5 + Tool Option Opsi alat + Tool Options… Opsi alat… + Toolbar: Advanced / Basic Bilah alat: lanjutan / dasar + Basic Dasar + Toolbar: Single/Double Column Bilah alat: kolom tunggal/ganda + Toolbar: Add Tool Bilah alat: tambahkan alat + Toolbar: Remove Tool Bilah alat: hapus alat + Toolbar: Reset Bilah alat: setel ulang + Two Point Perspective Preset 1 Preset perspektif dua titik 1 + Two Point Perspective Preset 2 Preset perspektif dua titik 2 + Two Point Perspective Preset 3 Preset perspektif dua titik 3 + Two Point Perspective Preset 4 Preset perspektif dua titik 4 + Two Point Perspective Preset 5 Preset perspektif dua titik 5 + Undo {name} Urungkan {name} + Undo {} Urungkan {} + User Graphic Style Library 1 Pustaka gaya grafis pengguna 1 + User Graphic Style Library 10 Pustaka gaya grafis pengguna 10 + User Graphic Style Library 2 Pustaka gaya grafis pengguna 2 + User Graphic Style Library 3 Pustaka gaya grafis pengguna 3 + User Graphic Style Library 4 Pustaka gaya grafis pengguna 4 + User Graphic Style Library 5 Pustaka gaya grafis pengguna 5 + User Graphic Style Library 6 Pustaka gaya grafis pengguna 6 + User Graphic Style Library 7 Pustaka gaya grafis pengguna 7 + User Graphic Style Library 8 Pustaka gaya grafis pengguna 8 + User Graphic Style Library 9 Pustaka gaya grafis pengguna 9 + User Swatch Library 1 Pustaka swatch pengguna 1 + User Swatch Library 10 Pustaka swatch pengguna 10 + User Swatch Library 2 Pustaka swatch pengguna 2 + User Swatch Library 3 Pustaka swatch pengguna 3 + User Swatch Library 4 Pustaka swatch pengguna 4 + User Swatch Library 5 Pustaka swatch pengguna 5 + User Swatch Library 6 Pustaka swatch pengguna 6 + User Swatch Library 7 Pustaka swatch pengguna 7 + User Swatch Library 8 Pustaka swatch pengguna 8 + User Swatch Library 9 Pustaka swatch pengguna 9 + View {n} Tampilan {n} + Width Point Edit… Edit titik lebar… + to search every command untuk mencari setiap perintah + Anchor Point Titik jangkar + Anchors: Jangkar: + Anchors Jangkar + Click Klik + Alt+Click Alt+klik + Shift+Click Shift+klik + Click an anchor point Klik titik jangkar + Click or drag Klik atau seret + Click the first point Klik titik pertama + Connect Selected End Points Hubungkan titik akhir terpilih + Convert Selected Anchor Points to Corner Konversi titik jangkar terpilih menjadi sudut + Convert Selected Anchor Points to Smooth Konversi titik jangkar terpilih menjadi halus + Convert: Konversi: + Double-click Klik ganda + Drag Seret + Alt+Drag Alt+seret + Shift+Drag Shift+seret + Drag a handle Seret pegangan + Enter Enter + Esc Esc + Fit All Muat semua + Fit on Screen Muat di layar + Help › Search Commands… Bantuan › Cari perintah… + Name* @ 66.67 % (RGB/Preview) Nama* @ 66.67 % (RGB/Pratinjau) + Name* @ 66.67% (RGB/Preview) Nama* @ 66.67% (RGB/Pratinjau) + No Document Tidak ada dokumen + Press Tekan + Search commands and tools Cari perintah dan alat + Style: Gaya: + Switch workspace Ganti ruang kerja + Show all open documents Tampilkan semua dokumen terbuka + Use Gunakan + the object to select | objek untuk memilihnya | + to select multiple objects | untuk memilih beberapa objek | + the object to duplicate objek untuk menduplikasinya + an anchor or path segment to select it | jangkar atau segmen jalur untuk memilihnya | + to move | untuk berpindah | + to add untuk menambahkan + to add a corner point | untuk menambah titik sudut | + to add a smooth point | untuk menambah titik halus | + to close | untuk menutup | + to finish untuk mengakhiri + to add smooth points | untuk menambah titik halus | + to toggle corner | untuk mengalihkan sudut | + to create point type | untuk membuat teks titik | + to create area type | untuk membuat teks area | + to exit editing untuk keluar dari pengeditan + to draw | untuk menggambar | + to constrain proportions | untuk mengunci proporsi | + from center | dari pusat | + for exact size untuk ukuran tepat + to set the reference point | untuk mengatur titik referensi | + to transform | untuk mengubah | + for exact values untuk nilai tepat + to pan the view untuk menggeser tampilan + to zoom in | untuk memperbesar | + to zoom out | untuk memperkecil | + to zoom into an area untuk memperbesar area tertentu + Hold Tahan + right or left to zoom in or out | ke kanan atau kiri untuk memperbesar atau memperkecil | + to keep zooming untuk terus memperbesar + an object to copy its attributes | objek untuk menyalin atributnya | + to apply the selection's to it | untuk menerapkan atribut seleksi ke objek itu | + to sample a color untuk mengambil sampel warna + across a selected object to set the gradient direction melintasi objek terpilih untuk mengatur arah gradien + to select an artboard | untuk memilih artboard | + on the canvas to create one di kanvas untuk membuatnya + a stroke to add a width point | stroke untuk menambah titik lebar | + to change one side | untuk mengubah satu sisi | + to select more points | untuk memilih lebih banyak titik | + a point to edit it | titik untuk mengeditnya | + to remove points untuk menghapus titik + the art to add a pin | karya seni untuk menambah pin | + to select more pins | untuk memilih lebih banyak pin | + a pin to warp | pin untuk mengubah bentuknya | + near a selected pin to rotate | di dekat pin terpilih untuk memutar | + to remove pins untuk menghapus pin + across paths to push them along | melintasi jalur untuk mendorongnya | + to size the brush ( untuk mengatur ukuran kuas ( + keeps its proportions) menjaga proporsinya) + over paths, and hold still to keep going | di atas jalur, dan tahan diam agar terus berlanjut | + over paths to roughen their outlines | di atas jalur untuk mengkasarkan garis luarnya | + to move in perspective | untuk berpindah dalam perspektif | + to copy | untuk menyalin | + while dragging to move perpendicular to the plane | saat menyeret untuk bergerak tegak lurus terhadap bidang | + to scale in perspective untuk mengubah skala dalam perspektif + an object, then another to blend them | objek, lalu objek lain untuk membuat blend | + to blend from it | untuk membuat blend darinya | + to set spacing and orientation untuk mengatur jarak dan orientasi + Apply Terapkan + Change Screen Mode (F) Ganti mode layar (F) + Color (,) Warna (,) + Draw Behind Gambar di belakang + Draw Inside Gambar di dalam + Draw Normal Gambar normal + Expand: Perluas: + Gradient (.) Gradien (.) + H: H: + Modify Ubah + Navigate Navigasi + None (/) Nihil (/) + Select All Pins Pilih semua pin + Show Mesh Tampilkan mesh + Toggle single/double column Alihkan kolom tunggal/ganda + W: W: + X: X: + Y: Y: + Drag to float the toolbar Seret untuk mengambangkan bilah alat + Drag to the window's left edge to dock Seret ke tepi kiri jendela untuk memasang + Crystallize Kristalisasi + Add Anchor Point Tool Alat tambah titik jangkar + Anchor Point Tool Alat titik jangkar + Arc Tool Alat busur + Area Graph Tool Alat grafik area + Area Type Tool Alat teks area + Artboard Tool Alat artboard + Bar Graph Tool Alat grafik batang + Blend Tool Alat blend + Bloat Tool Alat bloat + Blob Brush Tool Alat kuas blob + Column Graph Tool Alat grafik kolom + Crystallize Tool Alat kristalisasi + Delete Anchor Point Tool Alat hapus titik jangkar + Direct Selection Tool Alat seleksi langsung + Direct Selection Seleksi langsung + Ellipse Tool Alat elips + Eraser Tool Alat penghapus + Eyedropper Tool Alat pipet + Flare Tool Alat flare + Free Transform Tool Alat transformasi bebas + Gradient Tool Alat gradien + Group Selection Tool Alat seleksi grup + Group Selection Seleksi grup + Hand Tool Alat tangan + Hand Tangan + Join Tool Alat sambung + Lasso Tool Alat lasso + Lasso Lasso + Line Cut Tool Alat potong garis + Line Graph Tool Alat grafik garis + Line Segment Tool Alat segmen garis + Live Paint Selection Tool Alat seleksi Live Paint + Magic Wand Tool Alat tongkat ajaib + Measure Tool Alat pengukur + Measure Pengukur + Mesh Tool Alat mesh + Mirror & Cut Tool Alat cermin & potong + Paintbrush Tool Alat kuas + Path Eraser Tool Alat penghapus jalur + Pencil Tool Alat pensil + Perspective Grid Tool Alat kisi perspektif + Perspective Selection Tool Alat seleksi perspektif + Pie Graph Tool Alat grafik lingkaran + Polar Grid Tool Alat kisi polar + Polygon Tool Alat poligon + Print Tiling Tool Alat pengubinan cetak + Pucker Tool Alat pucker + Puppet Warp Tool Alat Puppet Warp + Radar Graph Tool Alat grafik radar + Rectangle Cut Tool Alat potong persegi panjang + Rectangle Tool Alat persegi panjang + Rectangular Grid Tool Alat kisi persegi panjang + Reflect Tool Alat pencerminan + Reshape Tool Alat pembentuk ulang + Rotate Tool Alat putar + Rotate View Tool Alat putar tampilan + Rotate View Putar tampilan + Rounded Rectangle Tool Alat persegi panjang bulat + Scale Tool Alat skala + Scallop Tool Alat scallop + Scatter Graph Tool Alat grafik sebar + Scissors Tool Alat gunting + Scissors Gunting + Selection Tool Alat seleksi + Shape Builder Tool Alat Shape Builder + Shaper Tool Alat Shaper + Shear Tool Alat kemiringan + Slice Selection Tool Alat seleksi iris + Slice Tool Alat iris + Smooth Tool Alat penghalus + Spiral Tool Alat spiral + Stacked Bar Graph Tool Alat grafik batang bertumpuk + Stacked Column Graph Tool Alat grafik kolom bertumpuk + Star Tool Alat bintang + Symbol Screener Tool Alat penyaring simbol + Symbol Scruncher Tool Alat perapatkan simbol + Symbol Shifter Tool Alat penggeser simbol + Symbol Sizer Tool Alat pengubah ukuran simbol + Symbol Spinner Tool Alat pemutar simbol + Symbol Sprayer Tool Alat penyemprot simbol + Symbol Stainer Tool Alat pewarna simbol + Symbol Styler Tool Alat penerap gaya simbol + Touch Type Tool Alat Touch Type + Touch Type Touch Type + Twirl Tool Alat puntir + Type on a Path Tool Alat teks di jalur + Type Tool Alat teks + Vertical Area Type Tool Alat teks area vertikal + Vertical Area Type Teks area vertikal + Vertical Type on a Path Tool Alat teks vertikal di jalur + Vertical Type on a Path Teks vertikal di jalur + Vertical Type Tool Alat teks vertikal + Vertical Type Teks vertikal + Warp Tool Alat warp + Width Tool Alat lebar + Wrinkle Tool Alat kerut + Zoom Tool Alat zoom + Action {n} Aksi {n} + Begin Recording Mulai merekam + Center on Artboard Pusatkan di artboard + Default Actions Aksi default + ▾ Default Actions ▾ Aksi default + Duplicate and Offset 10 pt Duplikatkan dan geser sejauh 10 pt + No actions. Press ● to record. Tidak ada aksi. Tekan ● untuk merekam. + Outline Text Garis luar teks + Play Current Selection Putar seleksi saat ini + Reflect Horizontal Pencerminan horizontal + Rotate 90° CW Putar 90° searah jarum jam + Set 1 Set 1 + Stop Playing/Recording Hentikan pemutaran/perekaman + Unite and Simplify Satukan dan sederhanakan + ● Recording ● Merekam + Align to Artboard Ratakan ke artboard + Align to Glyph Bounds Ratakan ke batas glif + Align to Key Object Ratakan ke objek kunci + Align to Selection Ratakan ke seleksi + Align To: Ratakan ke: + Cancel Key Object Batalkan objek kunci + Distribute Objects: Distribusikan objek: + Distribute Spacing: Distribusikan jarak: + Horizontal Distribute Center Distribusi horizontal kiri + Horizontal Distribute Left Distribusi horizontal tengah + Horizontal Distribute Right Distribusi horizontal kanan + Horizontal Distribute Space Distribusi jarak horizontal + Vertical Distribute Bottom Distribusi vertikal bawah + Vertical Distribute Center Distribusi vertikal tengah + Vertical Distribute Space Distribusi jarak vertikal + Vertical Distribute Top Distribusi vertikal atas + Add New Effect Tambahkan efek baru + Click to choose a swatch, Shift-click to mix a colour Klik untuk memilih swatch, Shift+klik untuk mencampur warna + Click to toggle visibility Klik untuk mengalihkan visibilitas + Dashed Putus-putus + Delete Selected Item Hapus item terpilih + Double-click to edit the characters' paint Klik ganda untuk mengedit pewarnaan karakter + Double-click to target the contents Klik ganda untuk menargetkan konten + Drag onto art to apply this appearance Seret ke karya seni untuk menerapkan penampilan ini + Duplicate Selected Item Duplikatkan item terpilih + Fill: Isian: + Hide Thumbnail Sembunyikan thumbnail + Mixed Appearances Penampilan campuran + Mixed Objects Objek campuran + No options Tidak ada opsi + No Selection Tidak ada seleksi + Offset X Offset X + Opacity: 50% Multiply Opasitas: 50% Multiply + Opacity: Default Opasitas: Default + Opacity: {value} Opasitas: {value} + Rectangle: Sunshine Persegi panjang: Sunshine + Redefine Graphic Style “{name}” Definisikan ulang gaya grafis “{name}” + Show Thumbnail Tampilkan thumbnail + Artboard Options: edit with the Artboard tool Opsi artboard: edit dengan alat artboard + Delete Artboards Hapus artboard + Delete Empty Artboards Hapus artboard kosong + Duplicate Artboards Duplikatkan artboard + Move Down Turunkan + Move Up Naikkan + Open a document to see its artboards. Buka dokumen untuk melihat artboard-nya. + Rearrange All Artboards Susun ulang semua artboard + Move Artwork with Artboard Pindahkan karya seni bersama artboard + Scale Artwork with Artboard Ubah skala karya seni bersama artboard + Add Selected Artwork (Alt: as one asset) Tambahkan karya seni terpilih (Alt: sebagai satu aset) + Add Selected Artwork as Multiple Assets Tambahkan karya seni terpilih sebagai beberapa aset + Double-click to rename Klik ganda untuk mengganti nama + Drag art here, or select it and click +. Hold Alt for one asset of several objects. Seret karya seni ke sini, atau pilih dan klik +. Tahan Alt untuk satu aset dari beberapa objek. + Export the selected assets (else all of them) Ekspor aset terpilih (jika tidak, semuanya) + Export {count} Selected Ekspor {count} terpilih + Select All Assets Pilih semua aset + Don't Show Center Jangan tampilkan pusat + Hide Note Sembunyikan catatan + Image Map: Peta gambar: + Note: Catatan: + Open the URL in the web browser Buka URL di browser web + Overprint Fill Overprint isian + Overprint Stroke Overprint stroke + Recent URLs URL terbaru + Reverse Path Direction Off Balik arah jalur nonaktif + Reverse Path Direction On Balik arah jalur aktif + Show Center Tampilkan pusat + Show Note Tampilkan catatan + URL: URL: + Use Even-Odd Fill Rule Gunakan aturan isian genap-ganjil + Use Non-Zero Winding Fill Rule Gunakan aturan isian lilitan tak nol + Brush Libraries (on the roadmap) Pustaka kuas (masih dalam roadmap) + List View Tampilan daftar + New Art Brush from Selection Kuas seni baru dari seleksi + New Bristle Brush Kuas bristle baru + New Calligraphic Brush Kuas kaligrafi baru + New Pattern Brush from Selection Kuas pola baru dari seleksi + New Scatter Brush from Selection Kuas sebar baru dari seleksi + No brushes Tidak ada kuas + Select art and use New Brush to make one. Pilih karya seni dan gunakan Kuas baru untuk membuatnya. + Show {kind} Brushes Tampilkan kuas {kind} + Thumbnail View Tampilan thumbnail + All Caps Semua kapital + Character Alignment Perataan karakter + Character Rotation Rotasi karakter + Em Box Bottom/Left Em Box bawah/kiri + Em Box Center Em Box tengah + Em Box Top/Right Em Box atas/kanan + Fractional Widths Lebar fraksional + Hide Options Sembunyikan opsi + Horizontal Scale % Skala horizontal % + Kerning (0 = Auto) Kerning (0 = Otomatis) + Leading Leading + No Break Tanpa pemenggalan + Proportional Metrics Metrik proporsional + Digits Digit + Arabic Arab + Hindi Hindi + Farsi Farsi + Reset Panel Setel ulang panel + Roman Baseline Baseline Romawi + Select a text object to edit its character attributes. Pilih objek teks untuk mengedit atribut karakternya. + Show Options Tampilkan opsi + Small Caps Kapital kecil + Small Caps (on the roadmap) Kapital kecil (masih dalam roadmap) + Standard Vertical Roman Alignment Perataan Romawi vertikal standar + Strikethrough Coret + Character: Karakter: + Character options Opsi karakter + Subscript Subskrip + Subscript (on the roadmap) Subskrip (masih dalam roadmap) + Superscript Superskrip + Superscript (on the roadmap) Superskrip (masih dalam roadmap) + System Layout Tata letak sistem + Tate-chu-yoko Tate-chu-yoko + Underline Garis bawah + Vertical Scale % Skala vertikal % + Axis Sumbu + ICF Top/Right ICF atas/kanan + ICF Bottom/Left ICF bawah/kiri + Copy Color Value (Hex) Salin nilai warna (Hex) + Create New Swatch… Buat swatch baru… + Name 40% Nama 40% + Out of Gamut Warning Peringatan di luar gamut + Out of Web Color Warning Peringatan di luar warna web + Web Safe RGB RGB aman web + {warning}: click to correct to the closest color ({hex}) {warning}: klik untuk mengoreksi ke warna terdekat ({hex}) + Edit or Apply Colors Edit atau terapkan warna + Limit colors to swatch library Batasi warna ke pustaka swatch + Limit colors to swatch library: {name} Batasi warna ke pustaka swatch: {name} + Save color group to Swatch panel Simpan grup warna ke panel Swatch + Save Colors as Swatches Simpan warna sebagai swatch + Save selected colors as swatches Simpan warna terpilih sebagai swatch + Set base color Atur warna dasar + Set base color to the current color Atur warna dasar ke warna saat ini + Show Tints/Shades Tampilkan warna muda/gelap + Show Vivid/Muted Tampilkan pekat/pudar + Show Warm/Cool Tampilkan hangat/dingin + Click to select, double-click to edit Klik untuk memilih, klik ganda untuk mengedit + Color Theme Tema warna + Delete theme Hapus tema + Edit Theme Edit tema + Edit theme Edit tema + Make a theme on Create and save it. Buat tema di Create lalu simpan. + My Themes Tema saya + New theme from the current color Tema baru dari warna saat ini + No saved themes Tidak ada tema tersimpan + Rule: Aturan: + Save changes to {name} Simpan perubahan ke {name} + Save theme to My Themes Simpan tema ke Tema saya + Set the selected color to the current color Atur warna terpilih ke warna saat ini + Copied the CSS of {rules} object(s) Menyalin CSS dari {rules} objek + Copy All Styles Salin semua gaya + Exported {path} Diekspor ke {path} + Generate CSS: the whole document's CSS while nothing is selected Buat CSS: CSS seluruh dokumen saat tidak ada yang dipilih + No CSS Tidak ada CSS + Select objects to see their CSS, or Generate CSS for the whole document. Pilih objek untuk melihat CSS-nya, atau buat CSS untuk seluruh dokumen. + The selected objects have no CSS of their own (see below). Objek terpilih tidak memiliki CSS tersendiri (lihat di bawah). + Units: {units} Unit: {units} + {count} more rules: Export writes every one. {count} aturan lagi: Ekspor menuliskan semuanya. + {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} objek tanpa nama tidak disertakan: beri nama, atau aktifkan Objek Tanpa Nama di menu panel. + All Categories Semua kategori + Save… Simpan… + Selection Only Hanya seleksi + {title} (selection) {title} (seleksi) + Changed since the last Refresh. Berubah sejak terakhir disegarkan. + Click to zoom in, Alt-click to zoom out, drag to pan, double-click to fit Klik untuk memperbesar, Alt+klik untuk memperkecil, seret untuk menggeser, klik ganda untuk memuat + Fit in Window Muat di jendela + Open a document to preview its flattening. Buka dokumen untuk mempratinjau flattening-nya. + Overprints: Overprint: + Preview the document as it is now Pratinjau dokumen seperti saat ini + Raster/Vector Balance: Keseimbangan raster/vektor: + Refresh Segarkan + Save Transparency Flattener Preset… Simpan preset flattener transparansi… + Basic Latin Latin dasar + Currency & Symbols Mata uang & simbol + Double-click to insert Klik ganda untuk menyisipkan + Entire Font Seluruh font + glyph {id} glif {id} + Greek & Cyrillic Yunani & Sirilik + Latin Extended Latin diperluas + Latin-1 Supplement Tambahan Latin-1 + No fonts are available. Tidak ada font yang tersedia. + Select text or click into text with the Type tool to insert glyphs Pilih teks atau klik di dalam teks dengan alat teks untuk menyisipkan glif + Show: Tampilkan: + Use the Selected Text's Font Gunakan font teks yang dipilih + Aspect Ratio Rasio aspek + Click the art with the Gradient tool to add or select points. Klik karya seni dengan alat gradien untuk menambah atau memilih titik. + Click the ramp or a type button to apply a gradient. Klik ramp atau tombol tipe untuk menerapkan gradien. + Color: Warna: + Delete Stop Hapus stop + Edit Gradient Edit gradien + Edit the selected point's colour in the Color panel Edit warna titik terpilih di panel Warna + Edit the stop Edit stop + Eyedropper: click the art to sample the stop's colour Pipet: klik karya seni untuk mengambil sampel warna stop + Gradient across stroke Gradien melintasi stroke + Gradient along stroke Gradien sepanjang stroke + Gradient Fill: click to apply, drag onto art Isian gradien: klik untuk menerapkan, seret ke karya seni + Gradient swatches Swatch gradien + Gradient within stroke Gradien di dalam stroke + Linear Gradient Gradien linier + Lines: clicks add points joined by a line Garis: klik menambahkan titik yang dihubungkan dengan garis + No gradient swatches Tidak ada swatch gradien + Points: clicks add free points Titik: klik menambahkan titik bebas + Radial Gradient Gradien radial + Reset to White, Black Setel ulang ke putih, hitam + Reverse Gradient Balikkan gradien + Save to Swatches Simpan ke swatch + Delete Graphic Style (Alt-click: without asking) Hapus gaya grafis (Alt+klik: tanpa konfirmasi) + Delete the graphic style “{name}”? Hapus gaya grafis “{name}”? + Delete these {n} graphic styles? Hapus {n} gaya grafis ini? + Delete this {n} graphic style? Hapus {n} gaya grafis ini? + Graphic Style Libraries Menu Menu pustaka gaya grafis + New Graphic Style (Alt-click to name it) Gaya grafis baru (Alt+klik untuk memberi nama) + New Graphic Style… Gaya grafis baru… + No graphic styles Tidak ada gaya grafis + Objects using them keep their look but are no longer linked. Objek yang menggunakannya tetap terlihat sama tetapi tidak lagi ditautkan. + Open a document to see its graphic styles. Buka dokumen untuk melihat gaya grafisnya. + Open Graphic Style Library Buka pustaka gaya grafis + Select styled art and click New Graphic Style. Pilih karya seni yang sudah diberi gaya lalu klik Gaya grafis baru. + Use Square for Previews Gunakan persegi untuk pratinjau + Use Text for Previews Gunakan teks untuk pratinjau + {percent}% opacity Opasitas {percent}% + History Options… Opsi riwayat… + Open a document to see its history. Buka dokumen untuk melihat riwayatnya. + Abutting Bersinggungan + Black and White Hitam dan putih + Corners Sudut + Auto Grouping Pengelompokan otomatis + Enhanced Ditingkatkan + Ignore Color Abaikan warna + Ignore White Abaikan putih + Palette: Palet: + Limited Terbatas + Full Tone Nada penuh + Document Library Pustaka dokumen + Library: Pustaka: + Tracing Presets Preset penelusuran + Method: Metode: + Mode: Mode: + Noise Derau + Create: Buat: + Fills Isian + Strokes Stroke + Overlapping Tumpang tindih + Paths: {paths} Anchors: {anchors} Colors: {colors} Jalur: {paths} Jangkar: {anchors} Warna: {colors} + Reset to Default Setel ulang ke default + Select an image to trace Pilih gambar untuk ditelusuri + Snap Curves To Lines Kancingkan kurva ke garis + Tracing Result with Outlines Hasil penelusuran dengan garis luar + Source Image Gambar sumber + Threshold Ambang batas + Trace Telusuri + Trace the image with the Default preset Telusuri gambar dengan preset Default + Size: {size} Tracking: {tracking} Ukuran: {size} Tracking: {tracking} + + Create New Layer Buat lapisan baru + Create New Sublayer Buat sublapisan baru + Search All Cari semua + Graphics Grafis + Local Libraries Pustaka lokal + Add Content Tambahkan konten + Click to apply to the selected type Klik untuk menerapkan ke teks terpilih + Create New Library… Buat pustaka baru… + Rename Library… Ganti nama pustaka… + Delete the library “{name}”? Hapus pustaka “{name}”? + Drag art or swatches here, or use + to add the selection's colors and text styles. Seret karya seni atau swatch ke sini, atau gunakan + untuk menambahkan warna dan gaya teks dari seleksi. + Its graphics, colors and text styles are deleted from this machine. Grafis, warna, dan gaya teks di dalamnya dihapus dari komputer ini. + Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. Simpan grafis, warna, dan gaya teks di sini untuk menggunakannya di dokumen mana pun. Pustaka disimpan di komputer ini — tanpa perlu akun. + This library is empty Pustaka ini kosong + No results Tidak ada hasil + No items in this library match “{query}”. Tidak ada item di pustaka ini yang cocok dengan “{query}”. + Right-click an item to move it into this group. Klik kanan item untuk memindahkannya ke grup ini. + New Group Grup baru + New Group… Grup baru… + Move to Group Pindahkan ke grup + No Group Tanpa grup + Rename Group… Ganti nama grup… + Delete Group Hapus grup + Import Library… Impor pustaka… + Export Library… Ekspor pustaka… + Close Library Tutup pustaka + Next Library Pustaka berikutnya + No matches. Tidak ada yang cocok. + Other Libraries Pustaka lain + Previous Library Pustaka sebelumnya + Swatch exchange files keep solid colors only. File pertukaran swatch hanya menyimpan warna solid. + This library is empty. Pustaka ini kosong. + Color Space Ruang warna + Created Dibuat + Dimensions Dimensi + Embedded {n} image(s) {n} gambar tersemat + Embedded {n} image(s); {missing} missing: relink them first {n} gambar tersemat; {missing} hilang: tautkan ulang dulu + File › Place… brings in an image, linked or embedded. File › Tempatkan… memasukkan gambar, tertaut atau tersemat. + Format Format + Medium Thumbnails Thumbnail sedang + Missing: {path} Hilang: {path} + Modified Diubah + Modified since it was read: {path} Diubah sejak terakhir dibaca: {path} + No Thumbnails Tanpa thumbnail + Nothing matches the Show filter. Tidak ada yang cocok dengan filter Tampilkan. + PPI PPI + Relinked {n} image(s) {n} gambar ditautkan ulang + Relinked {n} image(s); not in that folder: {} {n} gambar ditautkan ulang; tidak ada di folder tersebut: {} + Relinked {} image(s) {} gambar ditautkan ulang + Relink… Tautkan ulang… + Find Linked Files Temukan file tertaut + Find Linked Files in Folder… Temukan file tertaut di folder… + Choose the file to relink each linked file to. Files with the size and contents saved with the link come first. Pilih file untuk menautkan ulang setiap file yang tertaut. File dengan ukuran dan konten yang tersimpan bersama tautan ditampilkan lebih dulu. + Same file File yang sama + Same size Ukuran yang sama + Don't Relink Jangan tautkan ulang + Copy into the document's folder Salin ke folder dokumen + Select an image to see its Link Info. Pilih gambar untuk melihat info tautannya. + Show Embedded Tampilkan yang tersemat + Show Link Info Tampilkan info tautan + Show Modified Tampilkan yang diubah + Small Thumbnails Thumbnail kecil + Status Status + Unembedded to {} Sematan dilepas ke {} + Update Link Perbarui tautan + Updated {} image(s) {} gambar diperbarui + Break Link Putuskan tautan + Tolerance: Toleransi: + Transparency Options Opsi transparansi + Color Mixer (Shift-click the chip) Pencampur warna (Shift+klik chip) + Fill: click for swatches, Shift-click for the color mixer Isian: klik untuk swatch, Shift+klik untuk pencampur warna + Stroke: click for swatches, Shift-click for the color mixer Stroke: klik untuk swatch, Shift+klik untuk pencampur warna + This panel is on the roadmap (see the parity plan). Panel ini masih dalam roadmap (lihat rencana paritas). + Open a document to navigate it. Buka dokumen untuk menjelajahinya. + View Artboard Only Tampilkan artboard saja + Contextual Alternates Alternatif kontekstual + Default Figure Angka default + Discretionary Ligatures Ligatur opsional + Figure: Angka: + Fractions Pecahan + Ligatures are off while the text is tracked. Ligatur nonaktif saat teks diberi tracking. + Ordinals Ordinal + Proportional Oldstyle Oldstyle proporsional + Reset OpenType Features Setel ulang fitur OpenType + Select text to set its OpenType features. Pilih teks untuk mengatur fitur OpenType-nya. + Standard Ligatures Ligatur standar + Swash Swash + Tabular Lining Lining tabular + Tabular Oldstyle Oldstyle tabular + Every-line Composer Komposer tiap baris + First-line Left Indent Indentasi kiri baris pertama + Hyphenation… Pemenggalan kata… + Justification… Justifikasi… + Justify with last line aligned center Ratakan dengan baris terakhir rata tengah + Justify with last line aligned right Ratakan dengan baris terakhir rata kanan + Left Indent Indentasi kiri + Left-to-Right Paragraph Direction Arah paragraf kiri ke kanan + Line-end Punctuation Half Width Tanda baca akhir baris lebar setengah + Mojikumi Set Set Mojikumi + Kinsoku Set Set Kinsoku + Hard Keras + Right Indent Indentasi kanan + Right-to-Left Paragraph Direction Arah paragraf kanan ke kiri + Roman Hanging Punctuation Tanda baca gantung Romawi + Select a text object to edit its paragraph attributes. Pilih objek teks untuk mengedit atribut paragrafnya. + Single-line Composer Komposer satu baris + Space After Paragraph Spasi setelah paragraf + Space Before Paragraph Spasi sebelum paragraf + Top-to-Top Leading Leading dari atas ke atas + Bottom-to-Bottom Leading Leading dari bawah ke bawah + Burasagari Burasagari + Force Paksa + Expand applies to compound shapes (Alt-click a shape mode) — on the roadmap Perluas berlaku untuk bentuk majemuk (Alt+klik mode bentuk) — masih dalam roadmap + Expand Compound Shape Perluas bentuk majemuk + Make Compound Shape Buat bentuk majemuk + Pathfinder Options… Opsi Pathfinder… + Pathfinders: Pathfinder: + Release Compound Shape Lepaskan bentuk majemuk + Repeat Pathfinder Ulangi Pathfinder + Repeat {name} Ulangi {name} + Trap… Trap… + Bottom in Front Bawah di depan + Brick by Column Bata per kolom + Brick by Row Bata per baris + Brick Offset: Offset bata: + Copies: Salinan: + Dim Copies to: Redupkan salinan hingga: + H Spacing: Jarak H: + Height: Tinggi: + Hex by Column Heksagon per kolom + Hex by Row Heksagon per baris + Left in Front Kiri di depan + No document Tidak ada dokumen + Open a document to edit its patterns. Buka dokumen untuk mengedit polanya. + Overlap: Tumpang tindih: + Pattern options are available in pattern editing mode. Opsi pola tersedia dalam mode pengeditan pola. + Right in Front Kanan di depan + Select art and choose Object › Pattern › Make. Pilih karya seni lalu pilih Object › Pattern › Make. + Show Swatch Bounds Tampilkan batas swatch + Show Tile Edge Tampilkan tepi ubin + Size Tile to Art Sesuaikan ukuran ubin dengan karya seni + Tile Type: Jenis ubin: + Top in Front Atas di depan + V Spacing: Jarak V: + Width: Lebar: + Appearance panel Panel Tampilan + Arrange: Bring to Front Atur: Bawa ke depan + Draw: Gambar: + Edit Artboards Edit artboard + Flip Along Horizontal Axis Balik sepanjang sumbu horizontal + Flip Along Vertical Axis Balik sepanjang sumbu vertikal + Hyphenate Pemenggalan kata + Isolate Group Isolasi grup + Landscape Mendatar + More Pathfinder options Opsi Pathfinder lainnya + More Align options Opsi perataan lainnya + More Paragraph options Opsi paragraf lainnya + No document open Tidak ada dokumen terbuka + Quick Actions Tindakan cepat + Rearrange All Atur ulang semua + Rulers & Grids Penggaris & kisi + Select a text object Pilih objek teks + Show Grid (⌘') Tampilkan kisi (⌘') + Show Rulers (⌘R) Tampilkan penggaris (⌘R) + Smart Guides (⌘U) Panduan cerdas (⌘U) + Snap Options Opsi snap + Type: Jenis: + All colours are within the CMYK gamut Semua warna berada dalam gamut CMYK + Assign: Tetapkan: + Color Settings Pengaturan warna + Gamut Check Pemeriksaan gamut + Intent: Tujuan: + No document open. Tidak ada dokumen terbuka. + Overprint Preview (separations) Pratinjau overprint (separasi) + Show All Plates Tampilkan semua pelat + Show all plates Tampilkan semua pelat + Show/hide plate (Alt-click: only this plate) Tampilkan/sembunyikan pelat (Alt-klik: hanya pelat ini) + Simulate Paper Simulasikan kertas + Use Black Point Compensation Gunakan kompensasi titik hitam + Working CMYK (don't tag) CMYK kerja (tanpa tag) + {name} (spot) {name} (spot) + Add to Profiles… Tambahkan ke profil… + Align Stroke to Center Ratakan stroke ke tengah + Align Stroke to Inside Ratakan stroke ke dalam + Align Stroke to Outside Ratakan stroke ke luar + Align Stroke: Ratakan stroke: + Align: Ratakan: + Arrowheads: Mata panah: + Bevel Join Sambungan bevel + Butt Cap Ujung rata + Cap: Ujung: + Corner: Sudut: + End arrowhead Mata panah akhir + Exact dash lengths Panjang putus-putus yang persis + Fit dashes to corners and ends Sesuaikan putus-putus dengan sudut dan ujung + Flip Across Balik melintang + Flip Along Balik sepanjang + gap jarak + Limit: Batas: + Link start and end arrowhead scales Tautkan skala mata panah awal dan akhir + Miter Join Sambungan miter + Profile: Profil: + Projecting Cap Ujung menonjol + Round Cap Ujung bulat + Round Join Sambungan bulat + Scale: Skala: + Start arrowhead Mata panah awal + Stroke options Opsi stroke + Swap start and end arrowheads Tukar mata panah awal dan akhir + Tip extends past the end point Ujung melewati titik akhir + Tip on the end point Ujung pada titik akhir + Variable Width Profile Profil lebar variabel + Weight: Ketebalan: + Art using a deleted global swatch keeps its colour. Karya seni yang memakai swatch global terhapus tetap mempertahankan warnanya. + Color Group: {name} Grup warna: {name} + Delete these {count} swatches and groups? Hapus {count} swatch dan grup ini? + Delete “{name}”? Hapus “{name}”? + Document Swatches Swatch dokumen + Edit or Apply Color Group Edit atau terapkan grup warna + Edit or Apply Color Group… Edit atau terapkan grup warna… + Global Process Color Warna proses global + Global Process Color, CMYK Warna proses global, CMYK + Large List View Tampilan daftar besar + Large Thumbnail View Tampilan miniatur besar + Medium Thumbnail View Tampilan miniatur sedang + Open a document to see its swatches. Buka dokumen untuk melihat swatch-nya. + Open Swatch Library Buka pustaka swatch + Process Color Warna proses + Show All Swatches Tampilkan semua swatch + Show Color Groups Tampilkan grup warna + Show Color Swatches Tampilkan swatch warna + Show Find Field Tampilkan kolom pencarian + Show Gradient Swatches Tampilkan swatch gradien + Show List View Tampilkan tampilan daftar + Show Pattern Swatches Tampilkan swatch pola + Pattern Swatches Swatch pola + Show Swatch Kinds Tampilkan jenis swatch + Show Thumbnail View Tampilkan tampilan miniatur + Small List View Tampilan daftar kecil + Small Thumbnail View Tampilan miniatur kecil + Swatch Libraries Menu Menu pustaka swatch + User Library Pustaka pengguna + {kind} Gradient Gradien {kind} + {kind}, {model} {kind}, {model} + No symbols in this document Tidak ada simbol dalam dokumen ini + Select art and click New Symbol to make one. Pilih karya seni dan klik Simbol Baru untuk membuatnya. + Symbol Libraries (on the roadmap) Pustaka simbol (ada di peta jalan) + Align On: Ratakan pada: + Center-Justified Tab Tab rata tengah + Above Text Di atas teks + Position Panel Above Text Posisikan panel di atas teks + Click to add a tab stop, drag to move it, drag it off the ruler to delete it Klik untuk menambahkan tab stop, seret untuk memindahkannya, seret keluar dari penggaris untuk menghapusnya + Decimal-Justified Tab Tab rata desimal + Delete Tab Hapus tab + Leader: Leader: + Left-Justified Tab Tab rata kiri + No text selected Tidak ada teks yang dipilih + Repeat Tab Ulangi tab + Right-Justified Tab Tab rata kanan + Select a text object to set its tab stops. Pilih objek teks untuk mengatur tab stop-nya. + Select text Pilih teks + + Add Scale + Tambah skala + Add Selected Artwork as One Asset Tambahkan karya seni terpilih sebagai satu aset + Export Settings Pengaturan ekspor + No assets Tidak ada aset + Remove Selected Assets Hapus aset terpilih + Suffix Akhiran + Absolute Position Posisi absolut + Export All… Ekspor semua… + Export Selected CSS… Ekspor CSS terpilih… + No selection Tidak ada seleksi + Rasterize Unsupported Art Rasterisasi karya seni yang tidak didukung + Unnamed Objects Objek tanpa nama + Width and Height Lebar dan tinggi + Add New Tambah baru + Delete Point Hapus titik + Location: Lokasi: + Spread: Sebaran: + Clip to Bounding Box Pangkas ke kotak pembatas + Effective PPI PPI efektif + Embed Image(s) Sematkan gambar + Embedded Image Gambar tersemat + Large Thumbnails Miniatur besar + Linked Tertaut + Location Lokasi + No images Tidak ada gambar + Relink to Folder… Tautkan ulang ke folder… + Show Missing Tampilkan yang hilang + Sort by Status Urutkan berdasarkan status + Clear Overrides Hapus penimpaan + Create New {kind} Style Buat gaya {kind} baru + Delete {kind} Style Hapus gaya {kind} + Duplicate {kind} Style Duplikatkan gaya {kind} + New {kind} Style… Gaya {kind} baru… + Redefine {kind} Style Definisikan ulang gaya {kind} + {kind} Style Options… Opsi gaya {kind}… + Angle: Sudut: + Ellipse Properties: Properti elips: + Invert Pie Balikkan pai + Side Length: Panjang sisi: + Make Sides Equal Samakan sisi + Pie End Angle: Sudut akhir pai: + Pie Start Angle: Sudut awal pai: + Corner Radius: Radius sudut: + Radius: Radius: + Round Bulat + Flip Horizontal Balik horizontal + Flip Vertical Balik vertikal + Polygon Properties: Properti poligon: + Rectangle Properties: Properti persegi panjang: + Shape Properties: Properti bentuk: + Sides: Sisi: + Transform options Opsi transformasi + Transform Both Transformasikan keduanya + Transform Object Only Transformasikan objek saja + Transform Pattern Only Transformasikan pola saja + Use Registration Point for Symbol Gunakan titik registrasi untuk simbol + Click to edit the mask; Alt-click to view only the mask; Shift-click to disable or enable it Klik untuk mengedit masker; Alt-klik untuk melihat hanya masker; Shift-klik untuk menonaktifkan atau mengaktifkannya + Hide Thumbnails Sembunyikan miniatur + Invert Mask Balikkan masker + Isolate Blending Isolasi campuran + Knockout Group Grup knockout + Link the mask Tautkan masker + Make Mask Buat masker + Opacity & Mask Define Knockout Shape Opasitas & masker menentukan bentuk knockout + Select the art (and, on top of it, the mask object) Pilih karya seni (dan, di atasnya, objek masker) + Show Thumbnails Tampilkan miniatur + Stop editing the opacity mask Hentikan pengeditan masker opasitas + Unlink the mask Lepaskan tautan masker + Data Sets Set data + Data Set Set data + Select Bound Object Pilih objek terikat + Select All Bound Objects Pilih semua objek terikat + Multiple objects Beberapa objek + Graphics: {adapter} Grafis: {adapter} + Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft. Bagian dari ArtCraft. MIT OR Apache-2.0. Font: Source Sans 3, Inter, JetBrains Mono (OFL). Ikon: Lucide (ISC) + VectorCraft. + Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono, Noto Sans SC (OFL). Icons: Lucide (ISC) + VectorCraft. Bagian dari ArtCraft. MIT OR Apache-2.0. Font: Source Sans 3, Inter, JetBrains Mono, Noto Sans SC (OFL). Ikon: Lucide (ISC) + VectorCraft. + Version {version} — open-source vector illustration in pure Rust. Versi {version} — ilustrasi vektor sumber terbuka dalam Rust murni. + Align to Page Ratakan ke halaman + Smooth Color Warna mulus + Specified Distance Jarak yang ditentukan + Specified Steps Langkah yang ditentukan + Direction: Arah: + Scatter Brush Options Opsi kuas sebar + Art Brush Options Opsi kuas seni + Pattern Brush Options Opsi kuas pola + Scatter: Sebaran: + Rotation: Rotasi: + Rotation relative to: Rotasi relatif terhadap: + Brush Scale Options Opsi skala kuas + Scale Proportionately Skalakan proporsional + Stretch to Fit Stroke Length Regangkan sesuai panjang stroke + Stretch Between Guides Regangkan di antara panduan + Start: Awal: + End: Akhir: + Left to Right Kiri ke kanan + Right to Left Kanan ke kiri + Top to Bottom Atas ke bawah + Bottom to Top Bawah ke atas + Outer Corner Tile Ubin sudut luar + Side Tile Ubin sisi + Inner Corner Tile Ubin sudut dalam + Start Tile Ubin awal + End Tile Ubin akhir + Fit Pas + Stretch to fit Regangkan agar pas + Add space to fit Tambahkan spasi agar pas + Approximate path Perkiraan jalur + Colorization Pewarnaan + Tints Tint + Tints and Shades Tint dan shade + Hue Shift Pergeseran rona + Key Color: Warna kunci: + Size: Ukuran: + Calligraphic Brush Options Opsi kuas kaligrafi + Bristle Brush Options Opsi kuas bristle + Roundness: Kebulatan: + Shape: Bentuk: + Bristle Length: Panjang bristle: + Bristle Density: Kerapatan bristle: + Bristle Thickness: Ketebalan bristle: + Paint Opacity: Opasitas cat: + Stiffness: Kekakuan: + Round Point Titik bulat + Flat Point Titik rata + Round Blunt Tumpul bulat + Flat Blunt Tumpul rata + Round Curve Lengkung bulat + Flat Curve Lengkung rata + Round Angle Sudut bulat + Flat Angle Sudut rata + Round Fan Kipas bulat + Flat Fan Kipas rata + Adjust Options: Opsi penyesuaian: + Color Mode: Mode warna: + Convert Konversi + Global Global + Green Hijau + Shifts the tints of global and spot colors; other colors stay. Menggeser tint warna global dan warna spot; warna lainnya tetap. + Tint Tint + Color Guide Options Opsi panduan warna + Less Lebih sedikit + Steps: Langkah: + Variation: Variasi: + Variation Variasi + Color Models Model warna + Color Picker Pemilih warna + Color Swatches Swatch warna + Only Web Colors Hanya warna web + Original: click to restore Asli: klik untuk memulihkan + Ascender Ascender + Descender Descender + On Left Side Di sisi kiri + On Right Side Di sisi kanan + On Both Sides Di kedua sisi + Short Pendek + Full Width Lebar penuh + Chamfer Chamfer + Inverted Round Bulat terbalik + Bleed: Bleed: + Discard White Overprint in Output Buang overprint putih pada keluaran + Double Quotes: Tanda kutip ganda: + Export and Clipboard Transparency Flattener Settings Pengaturan perataan transparansi untuk ekspor dan clipboard + Export: Ekspor: + Grid Colors: Warna kisi: + Grid Size: Ukuran kisi: + Highlight Substituted Fonts Sorot font pengganti + Highlight Substituted Glyphs Sorot glyph pengganti + Language: Bahasa: + Position Posisi + Preserve Text Appearance Pertahankan tampilan teks + Preserve Text Editability Pertahankan kemampuan pengeditan teks + Show Images in Outline Mode Tampilkan gambar dalam mode garis luar + Simulate Colored Paper Simulasikan kertas berwarna + Single Quotes: Tanda kutip tunggal: + Small Caps: Kapital kecil: + Subscript: Subskrip: + Superscript: Superskrip: + Transparency Grid Kisi transparansi + Units: Satuan: + Use Typographer's Quotes Gunakan tanda kutip tipografis + Center Artwork Pusatkan karya seni + DXF Import Options Opsi impor DXF + Layout: Tata letak: + Merge Layers Gabungkan lapisan + Place DXF Tempatkan DXF + Scale Lineweights Skalakan ketebalan garis + Scale to Fit Artboard Skalakan agar pas dengan artboard + Scaling Penskalaan + Alter Paths for Appearance Ubah jalur untuk tampilan + DXF Options Opsi DXF + Export Option Opsi ekspor + Export Selected Art Only Ekspor hanya karya seni terpilih + Number of Colors: Jumlah warna: + Raster File Format: Format file raster: + True colour needs version 2004 or later: the nearest of 256 colours is written. Warna sejati memerlukan versi 2004 atau lebih baru: yang ditulis adalah warna terdekat dari 256 warna. + Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Teks menjadi garis luar, dan stroke yang tidak dapat digambar oleh garis CAD menjadi bentuk terisi. + Type stays text, and every stroke is a line with its lineweight and dashes. Teks tetap berupa teks, dan setiap stroke menjadi garis dengan ketebalan dan pola putus-putusnya. + Version: Versi: + Spin Putar + Good Baik + Best Terbaik + Amount Jumlah + Contrast Kontras + Edit the applied effect, or add another one? Edit efek yang diterapkan, atau tambahkan efek lain? + Input White Putih masukan + Output Black Hitam keluaran + Preserve Lightness Pertahankan kecerahan + Ridges per segment: Punggungan per segmen: + Temperature Suhu + {effect} is already applied {effect} sudah diterapkan + Odd Fields Bidang ganjil + Even Fields Bidang genap + Duplication Duplikasi + Interpolation Interpolasi + Anti-Alias Anti-alias + Bend: Lengkung: + Distort Appearance Distorsi tampilan + Distort Linear Gradients Distorsi gradien linear + Distort Pattern Fills Distorsi isian pola + Distortion Distorsi + Envelope Mesh Mesh amplop + Maintain Envelope Shape Pertahankan bentuk amplop + Preserve Shape Using: Pertahankan bentuk dengan: + Reset Envelope Mesh Setel ulang mesh amplop + Reset Envelope Shape Setel ulang bentuk amplop + V: V: + Warp Options Opsi warp + Compatible Gradient and Gradient Mesh Printing Pencetakan gradien dan mesh gradien yang kompatibel + Embed Fonts (for other applications) Sematkan font (untuk aplikasi lain) + EPS Options Opsi EPS + Include CMYK PostScript in RGB Files Sertakan PostScript CMYK dalam file RGB + Include Document Thumbnails Sertakan miniatur dokumen + Include Linked Files Sertakan file tertaut + Opaque Opak + PostScript: PostScript: + Transparency is flattened with the preset. Type is written as outlines; gradients as bands of colour. Transparansi diratakan dengan preset ini. Teks ditulis sebagai garis luar; gradien sebagai pita warna. + Transparency is flattened with the preset. Type is written as outlines; gradients stay smooth. Transparansi diratakan dengan preset ini. Teks ditulis sebagai garis luar; gradien tetap mulus. + Expand Gradient To Perluas gradien menjadi + Gradient Mesh Mesh gradien + Specify: Tentukan: + Export As Ekspor sebagai + One file per artboard: -.{ext} Satu file per artboard: -.{ext} + One page per artboard. Satu halaman per artboard. + The bounds of the visible art. Batas karya seni yang terlihat. + Use Artboards Gunakan artboard + Choose a folder Pilih folder + Create Sub-folders Buat subfolder + Export Asset Ekspor aset + Export to: Ekspor ke: + Format Settings Pengaturan format + Formats: Format: + Full Document Dokumen penuh + Include Bleed Sertakan bleed + No assets yet. Collect art with Object › Collect for Export, or drag it into the Asset Export panel. Belum ada aset. Kumpulkan karya seni dengan Object › Collect for Export, atau seret ke panel Ekspor Aset. + Open Location after Export Buka lokasi setelah ekspor + PDF Preset: Preset PDF: + Prefix: Prefiks: + Remove Hapus + Select: Pilih: + Your browser downloads the files (several as one .zip). Browser Anda mengunduh file tersebut (beberapa dijadikan satu .zip). + Cap Ujung + Dash Pattern Pola putus-putus + Eyedropper Applies: Pipet menerapkan: + Eyedropper Picks Up: Pipet mengambil: + Focal Fill Isian fokus + Focal Stroke Stroke fokus + Miter Limit Batas miter + Point Sample Sampel titik + Raster Sample Size: Ukuran sampel raster: + Weight Ketebalan + 3 x 3 Average Rata-rata 3 x 3 + 5 x 5 Average Rata-rata 5 x 5 + Author Title: Judul penulis: + Author: Penulis: + Copyright Info URL: URL info hak cipta: + Copyright Notice: Pemberitahuan hak cipta: + Copyright Status: Status hak cipta: + Created: Dibuat: + Dates Tanggal + Description: Deskripsi: + Document Title: Judul dokumen: + Keywords: Kata kunci: + Modified: Diubah: + Rating: Peringkat: + Remove “{keyword}” Hapus “{keyword}” + Separate several keywords with commas. Pisahkan beberapa kata kunci dengan koma. + Flare Tool Options Opsi alat flare + Rings Cincin + Brightness: Kecerahan: + Growth: Pertumbuhan: + Fuzziness: Kekaburan: + Number: Jumlah: + Longest: Terpanjang: + Path: Jalur: + Largest: Terbesar: + Anti-alias Rasters Anti-alias raster + Clip Complex Regions Pangkas wilayah kompleks + Convert All Strokes to Outlines Konversi semua stroke menjadi garis luar + Convert All Text to Outlines Konversi semua teks menjadi garis luar + Gradient and Mesh Resolution: Resolusi gradien dan mesh: + Line Art and Text Resolution: Resolusi seni garis dan teks: + Preserve Alpha Transparency Pertahankan transparansi alfa + Preserve Overprints and Spot Colors Pertahankan overprint dan warna spot + Rasters Raster + Replaces the saved preset of that name. Mengganti preset tersimpan dengan nama tersebut. + Save Preset… Simpan preset… + Vectors Vektor + A saved copy of the selected preset Salinan tersimpan dari preset terpilih + Built-in presets don't change: New makes a copy you can edit. Preset bawaan tidak berubah: Baru membuat salinan yang dapat Anda edit. + Horizontal % Horizontal % + Include Cmy Blacks Sertakan hitam CMY + Make all bleed settings the same Samakan semua pengaturan bleed + Make the bleed values differ Buat nilai bleed berbeda + Miter Limit: Batas miter: + Radius 1 Radius 1 + Radius 2 Radius 2 + Radius1 Radius1 + Radius2 Radius2 + Relative Relatif + Absolute Absolut + Vertical % Vertikal % + Miter Miter + Bevel Bevel + A lower value follows the stroke closely; a higher one gives fewer points and smoother curves. Nilai lebih rendah mengikuti stroke dengan cermat; nilai lebih tinggi menghasilkan lebih sedikit titik dan kurva yang lebih mulus. + Accurate Akurat + Close paths when ends are within: Tutup jalur bila ujungnya berada dalam jarak: + Edit selected paths within: Edit jalur terpilih dalam jarak: + Fidelity: Fidelitas: + Fill new strokes Isi stroke baru + The document has no colour swatches. Dokumen tidak memiliki swatch warna. + Style Name: Nama gaya: + Crop To Pangkas ke + e.g. 1-3, 5 mis. 1-3, 5 + First Page Halaman pertama + Import PDF Impor PDF + Last Page Halaman terakhir + Next Page Halaman berikutnya + of {count} dari {count} + of dari + Previous Page Halaman sebelumnya + “{file}” is protected by a password. “{file}” dilindungi kata sandi. + Dim Images to: Redupkan gambar menjadi: + Light Blue Biru muda + Layers Panel Options Opsi panel lapisan + Row Size: Ukuran baris: + Show Layers Only Tampilkan lapisan saja + Thumbnails: Miniatur: + Anchor Points Titik jangkar + Brush Affects: Kuas memengaruhi: + Complexity: Kompleksitas: + Detail: Detail: + Global Brush Dimensions Dimensi kuas global + Horizontal: Horizontal: + In Tangent Handles Handel tangen masuk + Out Tangent Handles Handel tangen keluar + Show Brush Size Tampilkan ukuran kuas + Simplify: Sederhanakan: + Twirl Rate: Laju puntir: + Use Pressure Pen Gunakan pena tekanan + Vertical: Vertikal: + {shortcut} with the tool sizes the brush Gunakan {shortcut} bersama alat untuk mengatur ukuran kuas + {tool} Options Opsi {tool} + {tool} Tool Options Opsi alat {tool} + These fonts aren't available. Their text shows in another font. Font ini tidak tersedia. Teksnya ditampilkan dengan font lain. + Search Fonts Folder Cari folder font + Add Fonts Tambahkan font + Find Fonts Temukan font + Stop Hentikan + Searching… Mencari… + Not found Tidak ditemukan + {folders} folder(s) and {files} file(s) searched {folders} folder dan {files} file telah dicari + {count} folder(s) skipped, such as other apps' and the system's folders and hidden ones {count} folder dilewati, seperti folder aplikasi lain, folder sistem, dan folder tersembunyi + {count} folder(s) or file(s) couldn't be read {count} folder atau file tidak dapat dibaca + The search stopped at its limit: search a smaller folder. Pencarian berhenti pada batasnya: cari folder yang lebih kecil. + Copy only fonts you own or are licensed to install. Salin hanya font yang Anda miliki atau yang dilisensikan untuk dipasang. + VectorCraft copies the fonts to {folder}. VectorCraft menyalin font ke {folder}. + Apply to All ({count} more missing) Terapkan ke semua ({count} lagi hilang) + Ignore Abaikan + Linked File Not Found File tertaut tidak ditemukan + Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Ganti dengan file lain, atau abaikan: gambarnya tetap menampilkan pratinjau yang tersimpan bersama dokumen. + Replace… Ganti… + Update Modified Links Perbarui tautan yang diubah + {count} linked image(s) changed since this document was saved. Show the new versions? {count} gambar tertaut berubah sejak dokumen ini disimpan. Tampilkan versi barunya? + Dialog Dialog + Convert Process to Global Konversi proses ke global + Create From: Buat dari: + Include Swatches for Tints Sertakan swatch untuk tint + Selected Artwork Karya seni terpilih + Selected Swatches Swatch terpilih + Advanced Options Opsi lanjutan + Background Contents Konten latar belakang + Background Contents: Konten latar belakang: + BLANK DOCUMENT PRESETS PRESET DOKUMEN KOSONG + Bleed Bleed + Change to Right-to-Left Layout Ubah ke tata letak kanan ke kiri + Columns: Kolom: + Create Document Buat dokumen + Documents you create appear here. Dokumen yang Anda buat muncul di sini. + More Settings Pengaturan lainnya + New from Template Baru dari templat + Number of Artboards: Jumlah artboard: + Orientation: Orientasi: + PRESET DETAILS DETAIL PRESET + Preset Name Nama preset + Preset {} Preset {} + Presets you save with the Save Preset button appear here. Preset yang Anda simpan dengan tombol Simpan Preset muncul di sini. + Preview Mode Mode pratinjau + RECENT TERKINI + RECENT (1) TERKINI (1) + SAVED TERSIMPAN + Templates… Templat… + Transparent (Default) Transparan (default) + Save for Office Documents Simpan untuk dokumen Office + Transparent Background Latar belakang transparan + Choose… Pilih… + Copy Fonts Used in the Document Salin font yang digunakan dalam dokumen + Copy Links Salin tautan + Create Report Buat laporan + Fonts whose licence doesn't allow embedding are not copied. Font yang lisensinya tidak mengizinkan penyematan tidak disalin. + Package collects a saved document with its linked files and fonts. Package mengumpulkan dokumen tersimpan beserta file dan font yang tertaut. + Package created Paket dibuat + Packaged {n} file(s) in {folder}{note} {n} file dipaketkan dalam {folder}{note} + Packaged {n} file(s){note} {n} file dipaketkan{note} + Relink Linked Files to the Document Tautkan ulang file tertaut ke dokumen + Save the document first? Simpan dokumen terlebih dahulu? + {count} file(s) in {folder}{note}. Show the package folder? {count} file dalam {folder}{note}. Tampilkan folder paket? + {count} linked file(s) not found {count} file tertaut tidak ditemukan + fonts not copied: {fonts} font tidak disalin: {fonts} + Built-in presets are read-only: New… starts an editable copy. Preset bawaan bersifat hanya-baca: Baru… memulai salinan yang dapat diedit. + 1 {unit} on the artboard stands for {real} {unit} in the scene. 1 {unit} pada artboard mewakili {real} {unit} dalam scene. + Grid Color & Opacity Warna & opasitas kisi + Gridline every: Garis kisi setiap: + Horizon Height: Tinggi horizon: + Horizontal Grid: Kisi horizontal: + Left Grid: Kisi kiri: + Perspective Grid Settings Pengaturan kisi perspektif + Real World: Dunia nyata: + Right Grid: Kisi kanan: + Save Grid as Preset Simpan kisi sebagai preset + Save these settings as a preset Simpan pengaturan ini sebagai preset + Third Vanishing Point: Titik hilang ketiga: + Viewing Angle: Sudut pandang: + Viewing Distance: Jarak pandang: + Show Active Plane Widget Tampilkan widget bidang aktif + Widget Position: Posisi widget: + Copy All Objects Salin semua objek + Do Not Move Jangan pindahkan + Horizontal Plane Bidang horizontal + Left Plane Bidang kiri + Move All Objects Pindahkan semua objek + Right Plane Bidang kanan + Built-in presets don't change: New… starts an editable copy. Preset bawaan tidak berubah: Baru… memulai salinan yang dapat diedit. + One Point Satu titik + Perspective grid presets Preset kisi perspektif + Three Point Tiga titik + Two Point Dua titik + User Preset Preset pengguna + Click to place each file at 100%, or drag to size it; arrow keys switch files, Esc skips one. Klik untuk menempatkan setiap file pada 100%, atau seret untuk mengatur ukurannya; tombol panah beralih antar file, Esc melewati satu file. + Keep a link to an image file instead of only embedding it Pertahankan tautan ke file gambar alih-alih hanya menyematkannya + Place onto a new locked, dimmed template layer below the current layer Tempatkan pada lapisan templat baru yang terkunci dan diredupkan di bawah lapisan saat ini + Swap the selected object for the file, keeping its place and transform Tukar objek terpilih dengan file, mempertahankan tempat dan transformasinya + On: one locked object showing the document, updated when it changes. Aktif: satu objek terkunci yang menampilkan dokumen, diperbarui saat dokumen berubah. + Off: an editable copy of its art, embedded in this document. Nonaktif: salinan karya seninya yang dapat diedit, tersemat dalam dokumen ini. + Images: link to the file instead of only embedding it. Gambar: tautkan ke file alih-alih hanya menyematkannya. + VectorCraft documents: one locked object, updated when the file changes. Dokumen VectorCraft: satu objek terkunci, diperbarui saat file berubah. + Alignment: Perataan: + Applies when the linked file is relinked or updated. Berlaku saat file tertaut ditautkan ulang atau diperbarui. + Bounds (Fill Proportionally) Batas (isi proporsional) + Bounds (Fit Proportionally) Batas (pas proporsional) + File Dimensions Dimensi file + Transforms Transformasi + Background Color: Warna latar belakang: + Client-side (.html) Sisi klien (.html) + Color Reduction: Reduksi warna: + Lossless (lossy WebP isn't available yet) Tanpa kehilangan (WebP lossy belum tersedia) + Scans: Pemindaian: + Server-side (.map) Sisi server (.map) + {format} Options Opsi {format} + 0.2 (quality) to 100 (speed) 0.2 (kualitas) hingga 100 (kecepatan) + All the art Semua karya seni + Artboard {number} Artboard {number} + Auto-Rotate (turn the paper to each artboard) Rotasi otomatis (putar kertas ke setiap artboard) + Automatic flatness Flatness otomatis + Can't print these settings: see the Summary Tidak dapat mencetak dengan pengaturan ini: lihat Ringkasan + CMYK colours print with their own values; off, they go through the colour settings too. Warna CMYK dicetak dengan nilainya sendiri; bila nonaktif, warna tetap melalui pengaturan warna. + Collate Susun + Composite colours are converted to it; separations separate with it when it is a CMYK profile. Warna komposit dikonversi ke profil ini; separasi memisah dengannya bila merupakan profil CMYK. + Convert All Spot Colors to Process Konversi semua warna spot menjadi proses + Default Printer Printer default + Document Ink Options Opsi tinta dokumen + Download: Unduh: + Drag the art to move it on the paper Seret karya seni untuk memindahkannya di kertas + Each page prints as one image, at the document's raster effects resolution. Setiap halaman dicetak sebagai satu gambar, dengan resolusi efek raster dokumen. + Edit Print Preset Edit preset cetak + Emulsion: Emulsi: + Every option is at its default. Setiap opsi berada pada nilai defaultnya. + Flatness: Flatness: + Frequencies and angles are the output device's to apply. Frekuensi dan sudut diterapkan oleh perangkat keluaran. + General › Placement › X Umum › Penempatan › X + How colours outside the press's gamut are separated. Cara warna di luar gamut mesin cetak dipisahkan. + Ignore Artboards (all the art as one page) Abaikan artboard (semua karya seni sebagai satu halaman) + Image: Gambar: + Marks and Bleed Marka dan bleed + Media Media + New Print Preset Preset cetak baru + Next page Halaman berikutnya + No printer settings to open here Tidak ada pengaturan printer untuk dibuka di sini + None (keep transparency) Tidak ada (pertahankan transparansi) + Nothing to print Tidak ada yang dapat dicetak + Open a document to preview its pages Buka dokumen untuk melihat pratinjau halamannya + Output › Mode Keluaran › Mode + Overprint and Transparency Flattener Options Opsi overprint dan perataan transparansi + Place the pages with the placement again Tempatkan halaman lagi dengan penempatan tersebut + Placement: Penempatan: + Preserve CMYK Numbers Pertahankan angka CMYK + Previous page Halaman sebelumnya + Print Layers: Lapisan cetak: + Print Preset: Preset cetak: + Printer Printer + Printer profile: Profil printer: + Printer: Printer: + Rendering intent: Tujuan rendering: + Same As Source Sama seperti sumber + Save these settings as a print preset Simpan pengaturan ini sebagai preset cetak + Scaling: Penskalaan: + Separations list the document's inks here: one page per ink that prints. Separasi mencantumkan tinta dokumen di sini: satu halaman untuk setiap tinta yang dicetak. + Setup… Pengaturan… + Skip Blank Artboards Lewati artboard kosong + The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. Tepi kertas yang tidak dapat dijangkau printer: karya seni berada di dalamnya, dan pratinjau menampilkannya dengan garis putus-putus. + The page as it prints Halaman seperti saat dicetak + The Print Tiling tool placed the pages. Halaman ditempatkan oleh alat Print Tiling. + The system's settings of this printer Pengaturan sistem untuk printer ini + Tile Full Pages Ubin halaman penuh + Tile Range: Rentang ubin: + tile {number} ubin {number} + Tiles are numbered across, then down; leave the range empty for every tile. Ubin dinomori menyamping lalu ke bawah; biarkan rentang kosong untuk semua ubin. + Transverse (the page a quarter turn on the paper) Melintang (halaman diputar seperempat putaran pada kertas) + Unprintable margin: Margin yang tidak dapat dicetak: + {pages} in all, {count} per copy {pages} total, {count} per salinan + {page} of {count} {page} dari {count} + PDF File File PDF + [Default] is protected: New… starts an editable copy. [Default] dilindungi: Baru… memulai salinan yang dapat diedit. + A new preset starting from the selected one Preset baru yang dimulai dari preset terpilih + Differences from {preset}: Perbedaan dari {preset}: + Differences from [Default]: Perbedaan dari [Default]: + Differences from VectorCraft Default: Perbedaan dari VectorCraft Default: + Edit… Edit… + None. Tidak ada. + Presets: Preset: + Save the selected preset to a file Simpan preset terpilih ke file + Embed ICC Profile Sematkan profil ICC + Flat Image Gambar rata + Include Hidden Layers Sertakan lapisan tersembunyi + Maximum Editability Pengeditan maksimal + Options: Opsi: + Write Layers Tulis lapisan + Anti-alias Anti-alias + Around Object Di sekeliling objek + Background: Latar belakang: + Changing these settings changes how raster effects look when exported, flattened or expanded. Mengubah pengaturan ini mengubah tampilan efek raster saat diekspor, diratakan, atau diperluas. + Color Model: Model warna: + Create Clipping Mask Buat clipping mask + Preserve Spot Colors Pertahankan warna spot + Resolution: Resolusi: + Transparent Transparan + Layout Order: Urutan tata letak: + Change to Left-to-Right Layout Ubah ke tata letak kiri ke kanan + Artboards: {n} Artboard: {n} + Rows: Baris: + Assign Tetapkan + Color Group: Grup warna: + Color Library Pustaka warna + Colors: Warna: + Current Colors ({total}) Warna saat ini ({total}) + Current Colors Warna saat ini + Current Colors (0) Warna saat ini (0) + Harmony Rules: Aturan harmoni: + Harmony Rules Aturan harmoni + Keep this row's colors (exclude) Pertahankan warna baris ini (kecualikan) + Limit to Library: Batasi ke pustaka: + Link harmony colors Tautkan warna harmoni + Merge colors into one row Gabungkan warna ke dalam satu baris + No colors to recolor (preserved colors are left out). Tidak ada warna untuk diwarnai ulang (warna yang dipertahankan dikesampingkan). + Preserve: Pertahankan: + Randomly change color order Ubah urutan warna secara acak + Randomly change saturation and brightness Ubah saturasi dan kecerahan secara acak + Recolor this row Warnai ulang baris ini + Reset the rows to the artwork's colors Setel ulang baris ke warna karya seni + Separate colors into rows Pisahkan warna ke dalam baris + Unlink harmony colors Lepaskan tautan warna harmoni + 1 Color Job Pekerjaan 1 warna + 2 Color Job Pekerjaan 2 warna + 3 Color Job Pekerjaan 3 warna + Recover Documents Pulihkan dokumen + Restored documents open unsaved: save them to keep them. Dokumen yang dipulihkan terbuka dalam keadaan belum disimpan: simpan untuk mempertahankannya. + VectorCraft didn't quit normally last time. These documents had unsaved changes: VectorCraft terakhir kali tidak keluar secara normal. Dokumen berikut memiliki perubahan yang belum disimpan: + Do you want to save the changes you made to “{name}”? Apakah Anda ingin menyimpan perubahan yang Anda buat pada “{name}”? + Don't Save Jangan Simpan + Your changes will be lost if you don't save them. Perubahan Anda akan hilang jika tidak disimpan. + < 1 sec < 1 detik + [Unnamed] [Tanpa nama] + Amount: Jumlah: + Anti-aliasing: Anti-aliasing: + Artboard: Artboard: + Color Table Tabel warna + Color Table (4 colors) Tabel warna (4 warna) + Color Table ({n} color) Tabel warna ({n} warna) + Color Table ({n} colors) Tabel warna ({n} warna) + Convert to sRGB Konversi ke sRGB + Delete this saved preset Hapus preset tersimpan ini + Dither: Dither: + Downloaded {} file(s) {} file diunduh + Fit in View Sesuaikan ke tampilan + Format: Format: + WebP WebP + High Tinggi + ICC Profile Profil ICC + Image Size Ukuran gambar + Interlaced Interlaced + Lossy: Lossy: + Low Rendah + Matte: Matte: + Maximum Maksimum + Metadata: Metadata: + Needs the desktop app Memerlukan aplikasi desktop + Optimized Dioptimalkan + Order: Urutan: + Original Asli + 2-Up 2-Up + 4-Up 4-Up + Output: Keluaran: + Percent: Persen: + Preview in Browser needs the desktop app (the web downloads files instead) Pratinjau di Browser memerlukan aplikasi desktop (versi web mengunduh file sebagai gantinya) + Progressive Progresif + Quality {quality} Kualitas {quality} + Quality: Kualitas: + Reduction: Reduksi: + Save the settings as a preset Simpan pengaturan sebagai preset + Saved {n} files beside {path} Menyimpan {n} file di sebelah {path} + Shown with the optimised preview. Ditampilkan dengan pratinjau teroptimasi. + Slices: Slice: + Unlock Buka kunci + Unshift Unshift + Very High Sangat tinggi + Web Shift Web Shift + Web Snap: Web Snap: + {secs} sec {secs} dtk + Create PDF-Compatible File Buat file kompatibel PDF + Embed ICC Profiles Sematkan profil ICC + File Name: Nama file: + Save As Simpan sebagai + Save as Template Simpan sebagai templat + Save each artboard to a separate file Simpan setiap artboard ke file terpisah + All printer's marks Semua marka cetak + Bleeds Bleed + Bottom: Bawah: + Changes allowed: Perubahan yang diizinkan: + Color bars Batang warna + Color conversion: Konversi warna: + Color images Gambar berwarna + Compatibility: Kompatibilitas: + PDF 1.7 PDF 1.7 + VectorCraft Default VectorCraft Default + High Quality Print Cetak kualitas tinggi + [High Quality Print] [Cetak kualitas tinggi] + Press Quality Kualitas cetak + [Press Quality] [Kualitas cetak] + Smallest File Size Ukuran file terkecil + [Smallest File Size] [Ukuran file terkecil] + PDF/X-1a:2001 PDF/X-1a:2001 + [PDF/X-1a:2001] [PDF/X-1a:2001] + PDF/X-3:2002 PDF/X-3:2002 + [PDF/X-3:2002] [PDF/X-3:2002] + PDF/X-4:2010 PDF/X-4:2010 + [PDF/X-4:2010] [PDF/X-4:2010] + Keeps the document editable: VectorCraft reopens the PDF with nothing lost. For files you'll edit again. Menjaga dokumen tetap dapat diedit: VectorCraft membuka kembali PDF tanpa kehilangan apa pun. Untuk file yang akan Anda edit lagi. + Compress text and line art Kompres teks dan seni garis + Compression Kompresi + Compression › Color › Above Ppi Kompresi › Warna › Di atas Ppi + Compression: Kompresi: + Condition identifier: Pengidentifikasi kondisi: + Convert text to outlines Konversi teks menjadi outline + Create PDF layers from layers and sublayers Buat lapisan PDF dari lapisan dan sublapisan + Description Deskripsi + Destination: Tujuan: + Document open password Kata sandi buka dokumen + Document profile Profil dokumen + Edit PDF Preset Edit Preset PDF + Embed page thumbnails Sematkan miniatur halaman + Enable copying of text, images and other content Izinkan penyalinan teks, gambar, dan konten lainnya + Enable plaintext metadata Aktifkan metadata teks biasa + Enable text access for screen readers Aktifkan akses teks untuk pembaca layar + Encryption: {name} (set by Compatibility) Enkripsi: {name} (diatur oleh Kompatibilitas) + Error Galat + Every option matches {base}. Setiap opsi cocok dengan {base}. + Files of a PDF standard can't be password-protected. File standar PDF tidak dapat dilindungi kata sandi. + Files of this standard tag their colours with ICC profiles. File standar ini menandai warnanya dengan profil ICC. + Flattener preset: Preset flattener: + General › Thumbnails Umum › Miniatur + Grayscale images Gambar skala abu-abu + Include non-printing layers Sertakan lapisan non-cetak + Left: Kiri: + Mark as trapped Tandai sebagai ter-trap + Marks and Bleeds Tanda dan bleed + Monochrome images Gambar monokrom + Monochrome Monokrom + New PDF Preset Preset PDF baru + of characters used dari karakter yang digunakan + Offset: Offset: + On Aktif + Open password: Kata sandi buka: + Optimize for fast web view Optimalkan untuk tampilan web cepat + Output condition: Kondisi output: + Output Intent Output Intent + Output intent profile: Profil output intent: + Overprint and Transparency Flattener Overprint dan flattener transparansi + Overprint: Overprint: + Page information Informasi halaman + PDF 1.3 files have no transparency: the preset flattens it. File PDF 1.3 tidak memiliki transparansi: preset meratakannya. + PDF/A files carry their own output intent. File PDF/A memiliki output intent-nya sendiri. + PDF/X-1a files are CMYK: every colour is converted to the destination (blank: the output intent's CMYK profile), untagged. File PDF/X-1a adalah CMYK: setiap warna dikonversi ke tujuan (kosong: profil CMYK output intent), tanpa tag. + Permissions Izin + Permissions password: Kata sandi izin: + Preserve editing capabilities Pertahankan kemampuan pengeditan + Preset name: Nama preset: + Printer mark type: Jenis tanda printer: + Printing allowed: Pencetakan yang diizinkan: + Profile inclusion: Penyertaan profil: + Range: Rentang: + Registration marks Tanda registrasi + Registry name: Nama registri: + Require a password to open the document Wajibkan kata sandi untuk membuka dokumen + Restrict printing, editing and other tasks Batasi pencetakan, pengeditan, dan tugas lainnya + Right: Kanan: + Save as preset: Simpan sebagai preset: + Save PDF Simpan PDF + Security Keamanan + Standard: Standar: + Subset fonts below: Subset font di bawah: + the preset preset + Top: Atas: + Trim mark weight: Ketebalan tanda potong: + Trim marks Tanda potong + Use document bleed settings Gunakan pengaturan bleed dokumen + View PDF after saving Lihat PDF setelah disimpan + Warnings Peringatan + CMYK profile in effect Profil CMYK yang berlaku + Save Graphic Style Library Simpan pustaka gaya grafis + Selected Styles Only Hanya gaya terpilih + Selected Styles Only (0) Hanya gaya terpilih (0) + A File… File… + GPL palettes keep solid colours only, as RGB. Palet GPL hanya menyimpan warna solid, sebagai RGB. + Save Swatch Library Simpan pustaka swatch + VectorCraft Swatches (.vcswatches) VectorCraft Swatches (.vcswatches) + Save To: Simpan ke: + Saved to User Defined: {} Disimpan ke Buatan Pengguna: {} + Selected Swatches Only Hanya swatch terpilih + Selected Swatches Only (0) Hanya swatch terpilih (0) + User Defined Libraries Pustaka buatan pengguna + Copy Report Salin laporan + VectorCraft read this script without running it. VectorCraft membaca skrip ini tanpa menjalankannya. + Needs nothing beyond plain JavaScript Tidak memerlukan apa pun selain JavaScript biasa + Needs the script language's own objects or directives Memerlukan objek atau direktif milik bahasa skrip itu sendiri + Needs the document model Memerlukan model dokumen + Needs files Memerlukan file + Needs menu commands Memerlukan perintah menu + Needs dialogs Memerlukan dialog + Needs features only the reference app has Memerlukan fitur yang hanya ada di aplikasi referensi + Outside the document Di luar dokumen + Writes, moves or deletes files Menulis, memindahkan, atau menghapus file + Opens files in other apps Membuka file di aplikasi lain + Opens network connections Membuka koneksi jaringan + Loads native code Memuat kode native + Sends messages to other apps Mengirim pesan ke aplikasi lain + Reads or sets environment variables Membaca atau menetapkan variabel lingkungan + Runs code the checker can't read Menjalankan kode yang tidak dapat dibaca oleh pemeriksa + Changes the reference app's preferences Mengubah preferensi aplikasi referensi + Menu commands Perintah menu + Names the script builds as it runs: {count} Nama yang dibuat skrip saat berjalan: {count} + Document model Model dokumen + Object types: {names} Jenis objek: {names} + Directives Direktif + Includes Penyertaan + Checked Diperiksa + Not followed: outside the script's folder Tidak diikuti: di luar folder skrip + Includes itself Menyertakan dirinya sendiri + Not read Tidak dibaca + Not read: a limit was reached Tidak dibaca: batas tercapai + Compiled or not text Terkompilasi atau bukan teks + Problems Masalah + String not closed String tidak ditutup + Comment not closed Komentar tidak ditutup + Regular expression not closed Ekspresi reguler tidak ditutup + Template literal not closed Template literal tidak ditutup + XML literal not closed XML literal tidak ditutup + Bracket never closed Kurung tidak pernah ditutup + Closing bracket with no opening one Kurung penutup tanpa kurung pembuka + Syntax newer than ES3 Sintaks lebih baru dari ES3 + Unknown directive Direktif tidak dikenal + Not valid UTF-8: unreadable bytes were replaced UTF-8 tidak valid: byte yang tidak terbaca telah diganti + Mixed line endings Akhiran baris campuran + Nesting too deep to follow Penyarangan terlalu dalam untuk diikuti + line {line} baris {line} + lines {lines} baris {lines} + {file}, line {line} {file}, baris {line} + Compiled script: ask its author for the .jsx file Skrip terkompilasi: minta file .jsx kepada penulisnya + Too large to check Terlalu besar untuk diperiksa + Not a script for the reference app Bukan skrip untuk aplikasi referensi + Some lists were cut short at their limit. Beberapa daftar dipotong pada batasnya. + Line Segment Tool Options Opsi alat segmen garis + Alt: Alt: + Divide Horizontally Into Bagi horizontal menjadi + Divide Vertically Into Bagi vertikal menjadi + Matte Matte + Message: Pesan: + N slices …, evenly spaced N irisan …, berjarak sama + per slice per irisan + Slice Type: Jenis irisan: + slices across, evenly spaced irisan melintang, berjarak sama + slices down, evenly spaced irisan ke bawah, berjarak sama + Target: Target: + Text Displayed in Cell: Teks yang ditampilkan dalam sel: + The cell shows the type object's text as HTML. Sel menampilkan teks dari objek teks sebagai HTML. + Lab Spot Colors: Warna spot Lab: + Lab Spot Colors Warna spot Lab + Lab Values Nilai Lab + Show and print from the Lab definition; PDF files carry it as the ink's alternate color. Tampilkan dan cetak dari definisi Lab; file PDF membawanya sebagai warna alternatif tinta. + Show and separate through the working CMYK space, as in older documents. Tampilkan dan pisahkan melalui ruang kerja CMYK, seperti pada dokumen lama. + CMYK Equivalents Padanan CMYK + Convert To Outlines Konversi ke outline + Decimal: Desimal: + Embed Fonts (Glyphs Used) Sematkan font (glyph yang digunakan) + Fewer Elements Lebih sedikit elemen + Font: Font: + Hide Code Sembunyikan kode + Images: Gambar: + Include Metadata Sertakan metadata + Internal CSS CSS internal + Layer Names Nama lapisan + Minify Minify + Minimal Minimal + Object IDs: ID objek: + Presentation Attributes Atribut presentasi + Preserve Editing Capabilities Pertahankan kemampuan pengeditan + Responsive Responsif + Show Code Tampilkan kode + Style Attributes Atribut gaya + Style Attributes (Entity References) Atribut gaya (referensi entitas) + Styling: Penataan gaya: + SVG Profile: Profil SVG: + SVG 1.1 SVG 1.1 + Unicode (UTF-16) Unicode (UTF-16) + Unicode (UTF-8) Unicode (UTF-8) + Unique Unik + Conflict {n} of {count} Konflik {n} dari {count} + Keep the pasted color as a new swatch with a numbered name. Simpan warna yang ditempel sebagai swatch baru dengan nama bernomor. + Pasted Ditempel + This document already has a swatch named “{name}” with another color. Dokumen ini sudah memiliki swatch bernama “{name}” dengan warna lain. + Use the document's swatch: pasted colors take its color. Gunakan swatch dokumen: warna yang ditempel mengambil warnanya. + Color Type: Jenis warna: + Swatch Name: Nama swatch: + Diameter: Diameter: + Symbolism Tools Options Opsi alat simbolisme + Symbol Set Density: Kepadatan set simbol: + Intensity: Intensitas: + Place PDF Tempatkan PDF + Collect Links in a Separate Folder Kumpulkan tautan dalam folder terpisah + Folder Name: Nama folder: + Color Management Manajemen warna + Summary Ringkasan + Download Unduh + Export Artboard Ekspor artboard + Encoding: Pengodean: + UTF-16 (Unicode) UTF-16 (Unicode) + Line Endings: Akhiran baris: + LF (macOS, Linux) LF (macOS, Linux) + CRLF (Windows) CRLF (Windows) + Text Export Options Opsi ekspor teks + Character Set: Set karakter: + Join the lines of each paragraph Gabungkan baris setiap paragraf + Line Breaks Pemisah baris + Mac Mac + or more spaces with a tab atau lebih spasi dengan tab + Platform: Platform: + Remove blank lines between paragraphs Hapus baris kosong antarparagraf + Spaces Spasi + Text Import Options Opsi impor teks + Unicode Unicode + Byte Order: Urutan byte: + Compress (RLE) Kompres (RLE) + Depth: Kedalaman: + File Format: Format file: + Flip Row Order Balik urutan baris + LZW Compression Kompresi LZW + Copy (make a transformed copy) Salin (buat salinan yang ditransformasi) + Reference Point Titik referensi + Reflect X Pantulkan X + Reflect Y Pantulkan Y + Kind Jenis + Variable Variabel + no data set to edit tidak ada set data untuk diedit + New Name Nama baru + no variable to rename tidak ada variabel untuk diganti namanya + no data set to rename tidak ada set data untuk diganti namanya + Variable Options Opsi variabel + Define a variable first Tentukan variabel terlebih dahulu + Adjust Adjoining Width Points Sesuaikan titik lebar bersebelahan + Keep both sides the same width Pertahankan kedua sisi tetap sama lebar + Side 1: Sisi 1: + Side 2: Sisi 2: + Total Width: Lebar total: + Width Point Edit Edit titik lebar + Art Seni + Bristle Bristle + Calligraphic Kaligrafis + Dots Titik + Fixed Tetap + Pressure Tekanan + Random Acak + Scatter Sebar + Color Burn Bakar warna + Color Dodge Dodge warna + Darken Menggelapkan + Exclusion Pengecualian + Hard Light Cahaya keras + Lighten Menerangkan + Luminosity Luminositas + Overlay Menimpa + Soft Light Cahaya lembut + Crosshatch Arsir silang + Fine Dots Titik halus + Medium Dots Titik sedang + Grainy Dots Titik berbutir + Coarse Dots Titik kasar + Short Lines Garis pendek + Medium Lines Garis sedang + Long Lines Garis panjang + Short Strokes Goresan pendek + Medium Strokes Goresan sedang + Long Strokes Goresan panjang + Soft Lembut + Screen Layar +axis Horizontal Horizontal +axis Vertical Vertikal + Sprinkles Taburan + Clumped Bergerombol + Contrasty Kontras + Enlarged Diperbesar + Stippled Bertitik + Speckle Bintik + Brick Bata + Burlap Goni + Canvas Kanvas + Sandstone Batu pasir + Bottom Left Kiri bawah + Top Left Kiri atas + Top Right Kanan atas + Bottom Right Kanan bawah +effect Soft Lembut + Blocks Blok + Frosted Buram + Tiny Lens Lensa kecil + Right Diagonal Diagonal kanan + Left Diagonal Diagonal kiri +effect Horizontal Horizontal +effect Vertical Vertikal + Diamond Wajik + Ellipse Elips + Add Effect Tambahkan efek + Add New Fill Tambahkan isian baru + Add New Stroke Tambahkan guratan baru + Appearance Item Item tampilan + Clear Appearance Hapus tampilan + Duplicate Item Duplikatkan item + Eyedropper Penadah warna + Move Appearance Pindahkan tampilan + Reduce to Basic Appearance Kurangi menjadi tampilan dasar + Remove Item Hapus item + Reorder Appearance Item Susun ulang item tampilan + Select Appearance Item Pilih item tampilan + Show All Hidden Attributes Tampilkan semua atribut tersembunyi + Target Contents Targetkan konten +theme Appearance Tampilan + Asset Export Settings Pengaturan ekspor aset + Assets Aset + Export Assets Ekspor aset + Remove Asset Hapus aset + Rename Asset Ganti nama aset + Fill Rule Aturan isian + Apply Brush Terapkan kuas + Break Link to Symbol Putuskan tautan ke simbol + Brush Definition Definisi kuas + Brush Options… Opsi kuas… + Current Brush Kuas saat ini + Current Symbol Simbol saat ini + Delete Brush Hapus kuas + Delete Symbol Hapus simbol + Duplicate Brush Duplikatkan kuas + Duplicate Symbol Duplikatkan simbol + Edit Symbol Edit simbol + Expand Brush Strokes Perluas guratan kuas + New Brush… Kuas baru… + New Symbol Simbol baru + New Symbol… Simbol baru… + Place Symbol Instance Tempatkan instansi simbol + Redefine Symbol Definisikan ulang simbol + Remove Brush Stroke Hapus guratan kuas + Replace Symbol Ganti simbol + Select All Instances Pilih semua instansi + Symbol Sprayer Penyemprot simbol + Symbolism Tool Alat simbolisme + Image Trace Presets Preset telusur gambar + Live Paint Bucket Ember Live Paint + Live Paint Bucket (Stroke) Ember Live Paint (guratan) + Live Paint Info Info Live Paint + Shape Builder Pembangun bentuk + Shape Builder Regions Wilayah pembangun bentuk + Image Trace View Tampilan telusur gambar + Edge Tepi + Clipboard as PDF Clipboard sebagai PDF + Clipboard as PNG Clipboard sebagai PNG + Clipboard as Text Clipboard sebagai teks + Clipboard Formats Format clipboard + Load Image into Clipboard Muat gambar ke clipboard + Load Metafile into Clipboard Muat metafile ke clipboard + Load PDF into Clipboard Muat PDF ke clipboard + Load Text into Clipboard Muat teks ke clipboard + Clipboard as SVG Clipboard sebagai SVG + Load SVG into Clipboard Muat SVG ke clipboard + Swatch Conflicts Konflik swatch + Saturate Saturasi + Convert Color Konversi warna + Convert Document Color Mode Konversi mode warna dokumen + Gamut Warning Peringatan gamut + Load Color Profile… Muat profil warna… + Plates Pelat + Spot Color Warna spot + Add to Swatches Tambahkan ke Swatches + Delete Theme Hapus tema + Save Theme Simpan tema + dash putus-putus + Missing Hilang + [Normal Paragraph Style] [Gaya paragraf normal] + Arc Busur + Auto Size New Area Type Ukur otomatis teks area baru + Create Path Buat jalur + Create Text Buat teks + Drawing Mode Mode menggambar + Line Garis + Line Segment Segmen garis + Polar Grid Grid polar + Polygon Poligon + Rectangular Grid Grid persegi panjang + Horizontal Dividers: Pembagi horizontal: + Vertical Dividers: Pembagi vertikal: + Rounded Rectangle Persegi panjang bulat + Spiral Spiral + Export CSS Ekspor CSS + Generate CSS Buat CSS + Line Cut Potong garis + Mirror & Cut Pantulkan & potong + Rectangle Cut Potong persegi panjang + Active Perspective Plane Bidang perspektif aktif + Delete Width Point Hapus titik lebar + Draw in Perspective Gambar dalam perspektif + Move in Perspective Pindahkan dalam perspektif + Move Plane Pindahkan bidang + Nudge in Perspective Geser dalam perspektif + Puppet Warp Puppet Warp + Puppet Warp Pins Pin Puppet Warp + Show Perspective Grid Tampilkan grid perspektif + Width Point Titik lebar + Width Profile Profil lebar + Objects Objek + Paths Jalur + Add Guide Tambahkan panduan + Edit Selection Edit seleksi + Recall Selection Panggil kembali seleksi + Remove Guide Hapus panduan + Saved Selections Seleksi tersimpan + Select Guides Pilih panduan + Move Guide to Layer Pindahkan panduan ke lapisan + Blob Brush Kuas blob + Convert Anchor Point Konversi titik jangkar + Curvature Kelengkungan + Delete Anchor Point Hapus titik jangkar + Eraser Penghapus + Knife Pisau + Path Eraser Penghapus jalur + Pencil Pensil + Reshape Segment Bentuk ulang segmen + Split Path Pisahkan jalur + Activate Document Aktifkan dokumen + Delete Hapus + Document JSON JSON dokumen + Duplicate Duplikatkan + Find Objects Cari objek + Inspect Document Periksa dokumen + Inspect Object Periksa objek + Begin Undo Group Mulai grup urungkan + End Undo Group Akhiri grup urungkan + Apply Effect Terapkan efek + Duplicate Effect Duplikatkan efek + Effect Options Opsi efek + Effects Efek + Move Effect Pindahkan efek + Remove Effect Hapus efek + Expand Options Opsi perluasan + File Info Info file + Batch Batch + DXF Info Info DXF + Export Document Ekspor dokumen + Export DXF Ekspor DXF + Export EPS Ekspor EPS + Export for Screens Ekspor untuk layar + Export for Screens Settings Pengaturan ekspor untuk layar + Export PDF Ekspor PDF + File Formats Format file + Flattener presets Preset flattener + Format Options Opsi format + Layer Lapisan + Link Tautan + Model Model + Open Document Buka dokumen + Password Kata sandi + PDF Info Info PDF + PDF Settings Pengaturan PDF + Print presets Preset cetak + Save Document Simpan dokumen + Serialize Document Serialisasi dokumen + Shapes Bentuk + Outlines Outline + Artboard Artboard + Windows Jendela + Delete Transparency Flattener Preset Hapus preset flattener transparansi + Export Transparency Flattener Presets Ekspor preset flattener transparansi + Import Transparency Flattener Presets Impor preset flattener transparansi + Save Transparency Flattener Preset Simpan preset flattener transparansi + Transparency Flattener Presets Preset flattener transparansi + to ke + Missing Fonts Font yang hilang + Find Font Files Cari file font + Add Font Files Tambahkan file font + Font List Daftar font + Fonts in Document Font dalam dokumen + Refresh Font List Segarkan daftar font + Replace Font Ganti font + Select Text by Font Pilih teks berdasarkan font + Add Freeform Line Tambahkan garis bebas + Add Freeform Point Tambahkan titik bebas + Delete Freeform Point Hapus titik bebas + Edit Freeform Point Edit titik bebas + Freeform Gradient Gradien bebas + Select Freeform Point Pilih titik bebas + Split Freeform Line Pisahkan garis bebas + Gradient Vector Vektor gradien + Select Gradient Stop Pilih hentian gradien + Create Graph Buat grafik + Graph Data Data grafik + Graph Type Jenis grafik + Vector Halftone Halftone vektor + spot spot + Text Teks + Artboard Options Opsi artboard + Artboard Options… Opsi artboard… + Collect in New Layer Kumpulkan dalam lapisan baru + Delete Artboard Hapus artboard + Delete Selection Hapus seleksi + Duplicate Selection Duplikatkan seleksi + Highlight Rows Sorot baris + Layer Options Opsi lapisan + Layer Options… Opsi lapisan… + Make/Release Clipping Mask Buat/Lepaskan mask kliping + Move Rows Pindahkan baris + New Artboard Artboard baru + New Layer… Lapisan baru… + New Sublayer… Sublapisan baru… + Paste Remembers Layers Tempel mengingat lapisan + Reorder Susun ulang + Select Art Pilih karya seni + Set Current Layer Tetapkan lapisan saat ini + Flatten Artwork Ratakan karya seni + Locate Object Temukan objek + Lock Others Kunci lainnya + Merge Selected Gabungkan terpilih + Outline Others Outline lainnya + Preview All Layers Pratinjau semua lapisan + Release to Layers (Build) Lepaskan ke lapisan (buat) + Release to Layers (Sequence) Lepaskan ke lapisan (urutan) + Reverse Order Balik urutan + Show All Layers Tampilkan semua lapisan + Unlock All Layers Buka kunci semua lapisan + English: USA Inggris: USA + Move Pindahkan + Character Style Gaya karakter + Paragraph Style Gaya paragraf + Style Gaya + Create New Library Buat pustaka baru + Add to Library Tambahkan ke pustaka + Delete Library Hapus pustaka + Delete Library Item Hapus item pustaka + Import Library Impor pustaka + Export Library Ekspor pustaka + New Library Group Grup pustaka baru + Rename Library Group Ganti nama grup pustaka + Delete Library Group Hapus grup pustaka + Move Library Item to Group Pindahkan item pustaka ke grup + Rename Library Ganti nama pustaka + Show Library Tampilkan pustaka + Use Library Item Gunakan item pustaka + Graphic Grafis + Check Links Periksa tautan + Embed Image Sematkan gambar + Go To Link Buka tautan + Link Info Info tautan + Placement Options Opsi penempatan + Relink Tautkan ulang + Unembed Lepaskan sematan + Add Mesh Line Tambahkan garis mesh + Add Spine Point Tambahkan titik spine + Blend Info Info blend + Blend Options Opsi blend + Delete Mesh Point Hapus titik mesh + Delete Spine Point Hapus titik spine + Envelope Info Info envelope + Envelope Options Opsi envelope + Expand Gradient Mesh Perluas mesh gradien + Make Envelope Buat envelope + Move Envelope Mesh Point Pindahkan titik mesh envelope + Move Mesh Point Pindahkan titik mesh + Move Spine Point Pindahkan titik spine + Reshape Spine Segment Bentuk ulang segmen spine + Reset with Mesh Atur ulang dengan mesh + Reset with Warp Atur ulang dengan warp + Set Mesh Point Color Tetapkan warna titik mesh + Edit Opacity Mask Edit mask opasitas + Opacity Mask Mask opasitas + Stop Editing Opacity Mask Hentikan pengeditan mask opasitas + View Opacity Mask Lihat mask opasitas + New Opacity Masks Are Clipping Mask opasitas baru bersifat kliping + New Opacity Masks Are Inverted Mask opasitas baru bersifat terbalik + Rasterize Rasterkan + Transform Each Transformasi masing-masing + New Art Appearance Tampilan karya seni baru + New Art Has Basic Appearance Karya seni baru memiliki tampilan dasar + Delete Preset Hapus preset + High (300 ppi) Tinggi (300 ppi) + Medium (150 ppi) Sedang (150 ppi) + New Document Presets Preset dokumen baru + Phone 390×844 Ponsel 390×844 + Postcard Kartu pos + Grid by Row Grid per baris + Grid by Column Grid per kolom + Arrange by Row Susun per baris + Arrange by Column Susun per kolom + Save Preset Simpan preset + Screen (72 ppi) Layar (72 ppi) + Social Square Post 1080×1080 Postingan persegi media sosial 1080×1080 + Distribute Distribusikan + Distribute Spacing Distribusikan jarak + Enter Isolation Mode Masuk mode isolasi + Live Shape Properties Properti Live Shape + Nudge Geser + Object Properties Properti objek + Reflect Pantulkan + Rotate Putar + Set Bounds Tetapkan batas + Shear Miringkan + Disable Opacity Mask Nonaktifkan mask opasitas + Enable Opacity Mask Aktifkan mask opasitas + Link Opacity Mask Tautkan mask opasitas + Make Opacity Mask Buat mask opasitas + Opacity Mask Info Info mask opasitas + Opacity Mask Options Opsi mask opasitas + Page Isolated Blending Pencampuran terisolasi halaman + Page Knockout Group Grup knockout halaman + Release Opacity Mask Lepaskan mask opasitas + Transparency Info Info transparansi + Unlink Opacity Mask Putuskan tautan mask opasitas + Overprint Overprint + Package Paketkan + Apply Last Color Terapkan warna terakhir + Apply Last Gradient Terapkan gradien terakhir + Complement Komplemen + Default Fill and Stroke Isian dan guratan standar + Fill Isian + Fill and Stroke Isian dan guratan + Invert Balikkan + Recent Colors Warna terbaru + Swap Fill and Stroke Tukar isian dan guratan + Toggle Fill/Stroke Focus Alihkan fokus isian/guratan + Character / Paragraph Karakter / Paragraf + Copy Artboard Salin artboard + Cut Artboard Potong artboard + Move Artboard Up/Down Pindahkan artboard ke atas/bawah + Add Anchor (Pen) Tambah jangkar (pena) + Add Anchor Point Tambahkan titik jangkar + Average Rata-rata + Close Path Tutup jalur + Convert Anchor Points Konversi titik jangkar + Cut Path Potong jalur + Delete Anchor Points Hapus titik jangkar + Move Anchors Pindahkan jangkar + Move Direction Handle Pindahkan pegangan arah + Reshape Bentuk ulang + Set Anchors Tetapkan jangkar + Minus Front Kurangi depan + Offset Path Offset jalur + Simplify Sederhanakan + Split Into Grid Pisahkan menjadi grid + Total: Total: + Add Guides Tambahkan panduan + Unite Satukan + Delete Pattern Hapus pola + Done Selesai + Expand Repeat Perluas pengulangan + List Patterns Daftarkan pola + Repeat Options Opsi pengulangan + Save a Copy Simpan salinan + Transform Patterns Transformasikan pola + Delete PDF Preset Hapus preset PDF + Export PDF Presets Ekspor preset PDF + Import PDF Presets Impor preset PDF + PDF Preset Preset PDF + PDF Presets Preset PDF + Save PDF Preset Simpan preset PDF + Define Perspective Grid Tentukan grid perspektif + Delete Perspective Grid Preset Hapus preset grid perspektif + Export Perspective Grid Presets Ekspor preset grid perspektif + Import Perspective Grid Presets Impor preset grid perspektif + Perspective Grid Options Opsi grid perspektif + Perspective Grid Preset Preset grid perspektif + Perspective Grid Presets Preset grid perspektif + Save Perspective Grid Preset Simpan preset grid perspektif + Image Info Info gambar + Load Place Cursor Muat kursor penempatan + Placed File Info Info file yang ditempatkan + Install Plug-in Pasang plug-in + List Plug-ins Daftarkan plug-in + Plug-in Info Info plug-in + Remove Plug-in Hapus plug-in + Run Plug-in Jalankan plug-in + Smooth Haluskan + Corner Sudut + White Putih + 30° Angles Sudut 30° + 45° Angles Sudut 45° + 60° Angles Sudut 60° + 90° & 45° & 30° Angles Sudut 90° & 45° & 30° + 90° & 45° Angles Sudut 90° & 45° + 90° Angles Sudut 90° + Additional Plug-ins Folder Folder plug-in tambahan + Additional Fonts Folder Folder font tambahan + Alignment Guides Panduan perataan + Anchor Points, Handle, and Bounding Box Display Tampilan titik jangkar, pegangan, dan kotak pembatas + Anchor/Path Labels Label jangkar/jalur + Angles Sudut + Animated Zoom Zoom animasi + Anti-aliased Artwork Karya seni anti-alias + Appearance Panel Panel tampilan + Ask When Modified Tanya saat diubah + Auto-Collapse Iconic Panels Ciutkan otomatis panel ikon + Show Tool Group Labels Tampilkan label grup alat + Automatically Otomatis + Automatically Save Recovery Data Simpan otomatis data pemulihan + Baseline Shift Pergeseran garis dasar + Brightness Kecerahan + Canvas Color Warna kanvas + Centimeters Sentimeter + Command Click to Select Objects Behind Command-klik untuk memilih objek di belakang + Constrain Angle Sudut kendala + Constrain Width and Height Proportions Kendalikan proporsi lebar dan tinggi + Construction Guides Panduan konstruksi + Curvature Tool Alat kelengkungan + Data Recovery Pemulihan data + Default Language Bahasa standar + Disable Auto Add/Delete Nonaktifkan tambah/hapus otomatis + Display All Blacks Accurately Tampilkan semua warna hitam secara akurat + Display All Blacks as Rich Black Tampilkan semua warna hitam sebagai rich black + Display Bitmaps as Anti-aliased Images in Pixel Preview Tampilkan bitmap sebagai gambar anti-alias dalam pratinjau piksel + Display Options Opsi tampilan + Display Print Size at 100% Zoom Tampilkan ukuran cetak pada zoom 100% + Double Click To Isolate Klik ganda untuk mengisolasi + Dutch Belanda + East Asian Type Teks Asia Timur + Enable Background Export Aktifkan ekspor di latar belakang + Enable Background Save Aktifkan penyimpanan di latar belakang + Enable in-menu font previews Aktifkan pratinjau font dalam menu + Enable Missing Glyph Protection Aktifkan perlindungan glyph yang hilang + Enable Rubber Band for Aktifkan rubber band untuk + Enable Touch Gestures Aktifkan gestur sentuh + Enable Touch Workspace Aktifkan ruang kerja sentuh + English: UK Inggris: UK + Every (minutes) Setiap (menit) + Exceptions (comma separated) Pengecualian (dipisahkan koma) + Feet Kaki + Files File + Fill New Type Objects With Placeholder Text Isi objek teks baru dengan teks placeholder + Folder Folder + Font Preview Size Ukuran pratinjau font + Get Preferences Dapatkan preferensi + GPU Performance Performa GPU + Graphics Processor Prosesor grafis + Gridline every Garis grid setiap + Grids In Back Grid di belakang + Handles Pegangan + Hide Corner Widget for angles greater than Sembunyikan widget sudut untuk sudut lebih besar dari + High Performance (discrete) Performa tinggi (diskrit) + Highlight Alternate Glyphs Sorot glyph alternatif + Highlight anchors on mouse over Sorot jangkar saat kursor melintas + History States Status riwayat + Hollow Berongga + Identify Objects By Identifikasi objek berdasarkan + Include SVG Code Sertakan kode SVG + Italian Italia + Keyboard Increment Inkremen papan ketik + Language Options Opsi bahasa + Large Besar + Large Tabs Tab besar + Legacy vector clipboard Clipboard vektor lama + Legacy vector clipboard (no transparency) Clipboard vektor lama (tanpa transparansi) + Line Color Warna garis + Lines Garis + List Preferences Daftarkan preferensi + Manually Secara manual + Mark Older Files as [Converted] When Opened Tandai file lama sebagai [Converted] saat dibuka + Match User Interface Brightness Sesuaikan dengan kecerahan antarmuka pengguna + Light Gray Abu-abu terang + Medium Gray Abu-abu sedang + Dark Gray Abu-abu gelap + Measurement Labels Label pengukuran + Meters Meter + Move Locked and Hidden Artwork with Artboard Pindahkan karya seni terkunci dan tersembunyi dengan artboard + Number of Recent Files to Display Jumlah file terbaru yang ditampilkan + Number of Recent Fonts Jumlah font terbaru + Numbers Without Units Are Points Angka tanpa satuan adalah poin + Object Highlighting Penyorotan objek + Object Name Nama objek + Object Selection by Path Only Seleksi objek hanya melalui jalur + On Copy Saat menyalin + On Paste Saat menempel + On Screen Di layar + Open Documents As Tabs Buka dokumen sebagai tab + Options Opsi + Output All Blacks Accurately Keluarkan semua warna hitam secara akurat + Output All Blacks as Rich Black Keluarkan semua warna hitam sebagai rich black + Override Character Color Timpa warna karakter + Paste Offset Offset tempel + Pattern Editing Pengeditan pola + Pen Tool Alat pena + Picas Pica + Portuguese Portugis + Preserve Appearance and Overprints Pertahankan tampilan dan overprint + Preserve Paths Pertahankan jalur + Primary Utama + Printing / Exporting Pencetakan / Ekspor + Real-time Drawing and Editing Penggambaran dan pengeditan waktu nyata + Render Threads (-1 = Automatic) Utas render (-1 = Otomatis) + Reset Preferences Atur ulang preferensi + Scale Corners Skalakan sudut + Scale Cursor Proportional to UI Skalakan kursor proporsional dengan UI + Scale Strokes & Effects Skalakan guratan & efek + Scratch Disks Disk scratch + Secondary Sekunder + Select Same Tint % Pilih tint yang sama % + Set Preference Tetapkan preferensi + Show East Asian Options Tampilkan opsi Asia Timur + Show Font Names in English Tampilkan nama font dalam bahasa Inggris + Show handles when multiple anchors are selected Tampilkan pegangan saat beberapa jangkar dipilih + Show Indic Options Tampilkan opsi Indic + Show Pixel Grid (Above 600% Zoom) Tampilkan grid piksel (di atas zoom 600%) + Show Slice Numbers Tampilkan nomor irisan + Show The Home Screen When No Documents Are Open Tampilkan layar beranda saat tidak ada dokumen terbuka + Show Tool Tips Tampilkan tips alat + Size/Leading Ukuran/Leading + Snapping Tolerance Toleransi kancing + Spacing Guides Panduan jarak + Spanish Spanyol + Subdivisions Subdivisi + Templates Folder Folder templat + Tile Edge Color Warna tepi ubin + Tolerance Toleransi + Tracking Tracking + Transform Panel Panel transformasi + Transform Pattern Tiles Transformasi ubin pola + Transform Tools Alat transformasi + Turn off Data Recovery for complex documents Matikan Pemulihan Data untuk dokumen kompleks + Type Object Selection by Path Only Pemilihan objek teks hanya berdasarkan jalur + UI Scaling Penskalaan UI + Use Compression Gunakan kompresi + Use Japanese Crop Marks Gunakan tanda potong Jepang + Use Low Resolution Proxy for Linked EPS Gunakan proksi resolusi rendah untuk EPS tertaut + Use Precise Cursors Gunakan kursor presisi + Use Preview Bounds Gunakan batas pratinjau + When pasting text Saat menempel teks + XML ID ID XML + Yards Yard + Zoom to Selection Zum ke seleksi + Zoom with Mouse Wheel Zum dengan roda mouse + Scrub Numeric Fields by Dragging Atur nilai kolom numerik dengan menyeret + Linux Display Server Server tampilan Linux + Print Preview Pratinjau cetak + Print Setup Pengaturan cetak + Bitmap Bitmap + Delete Print Preset Hapus preset cetak + Export Print Presets Ekspor preset cetak + Import Print Presets Impor preset cetak + Print Preset Preset cetak + Print Presets Preset cetak + Save Print Preset Simpan preset cetak + Print Tiling Pages Halaman pengubinan cetak + Document Raster Effects Settings Pengaturan efek raster dokumen + Artwork Colors Warna karya seni + Randomize Colors Acak warna + Recolor Artwork Warnai ulang karya seni + Reduce Colors Kurangi warna + Discard Recovered Documents Buang dokumen yang dipulihkan + Recovery Data Data pemulihan + Restore Recovered Documents Buka kembali dokumen yang dipulihkan + Save Recovery Data Simpan data pemulihan + Untitled Tanpa judul + Add to Selection Tambahkan ke seleksi + Select Anchors Pilih jangkar + Select Segments Pilih segmen + Select Objects Pilih objek + Toggle Selection Alihkan seleksi + Create Slice Buat irisan + Delete Slices Hapus irisan + Divide Slices Bagi irisan + List Slices Daftar irisan + Move Slices Pindahkan irisan + Select Slices Pilih irisan + Set Slice Rectangle Atur persegi panjang irisan + Slice Options Opsi irisan + Add to Profiles Tambahkan ke profil + Delete Profile Hapus profil + Reset Profiles Atur ulang profil + Stroke Options Opsi goresan + Width Profiles Profil lebar + Apply Graphic Style Terapkan gaya grafis + Break Link to Graphic Style Putuskan tautan ke gaya grafis + Delete Graphic Style Hapus gaya grafis + Duplicate Graphic Style Duplikatkan gaya grafis + Graphic Style Options Opsi gaya grafis + Graphic Styles Options Opsi gaya grafis + Default Graphic Style Gaya grafis default + Black Outline Garis tepi hitam + Heavy Ink Tinta pekat + Sunshine Sinar matahari + List Graphic Styles Daftar gaya grafis + Merge Graphic Styles Gabungkan gaya grafis + Move Graphic Style Pindahkan gaya grafis + New Graphic Style Gaya grafis baru + Redefine Graphic Style Definisikan ulang gaya grafis + Rename Graphic Style Ganti nama gaya grafis + Add to Graphic Styles Tambahkan ke gaya grafis + Graphic Style Library Pustaka gaya grafis + Add Used Colors Tambahkan warna yang digunakan + Delete Swatch Hapus sampel + Duplicate Swatch Duplikatkan sampel + Merge Swatches Gabungkan sampel + Move Swatch Pindahkan sampel + New Color Group Grup warna baru + New Swatch Sampel baru + Select All Unused Pilih semua yang tidak digunakan + Sort by Kind Urutkan berdasarkan jenis + Sort by Name Urutkan berdasarkan nama + Spot Colors Warna spot + Spot Colors (0) Warna spot (0) + Swatch Options Opsi sampel + Ungroup Color Group Lepaskan grup warna + Copy to User Defined Salin ke Buatan Pengguna + Swatch Library Pustaka sampel + Clear All Tabs Hapus semua tab + Set Tab Stops Atur hentian tab + Tab Stops Hentian tab + Flare Flare + Rays Sinar + Rectangle Persegi panjang + Paintbrush Kuas lukis + Pen Pena + Small Kecil + Black and White Logo Logo hitam putih + High Fidelity Photo Foto fidelitas tinggi + Low Fidelity Photo Foto fidelitas rendah + 3 Colors 3 warna + 6 Colors 6 warna + Shades of Gray Nuansa abu-abu + Sketched Art Seni sketsa + Silhouettes Siluet + Line Art Seni garis + Technical Drawing Gambar teknis + Flat Logo Logo flat + Keep Formatting Pertahankan pemformatan + Keep Plain Text Pertahankan teks polos + Clip Klip + PDF PDF + no document tidak ada dokumen + Blue Biru + Brown Cokelat + Gray Abu-abu + Orange Oranye + Pink Merah muda + Purple Ungu + Custom Kustom + Document Setup Pengaturan dokumen + French Prancis + German Jerman + Inches Inci + Japanese Jepang + Millimeters Milimeter + Points Titik +effect Points Titik + Adjust Colors Sesuaikan warna + Place Tempatkan + Discard Empty Type Buang teks kosong + Copyright Hak cipta + Other Lainnya + Print Cetak + Solid Padat + Difference Difference + Flatten Transparency Ratakan transparansi + Fonts Font + Off Nonaktif + above di atas + Square Persegi + Update Links Perbarui tautan + Liquify Liquify + Corner Radius Radius sudut + Move Guide Pindahkan panduan +axis Both Keduanya + Letter Letter + Portrait Potret + Recent Terkini + Saved Tersimpan + Web 1920×1080 Web 1920×1080 + Yellow Kuning + Auto Otomatis + Center Tengah + Transform in Perspective Transformasi dalam perspektif + Grayscale Skala abu-abu + Embed Sematkan + Star Bintang + Power Saving (integrated) Hemat daya (terintegrasi) + Target Target + X11 (XWayland) X11 (XWayland) + Print as Bitmap Cetak sebagai bitmap + Draft Draf + Default Default + [VectorCraft Default] [Default VectorCraft] + Magenta Magenta + Page Halaman + Print Tiling Pengubinan cetak + Edit Color Group Edit grup warna + Marks Tanda + Color Mode Mode warna + Templates Templat + Halo Halo + Multiply Multiply + Red Merah + Grays Abu-abu + Color Group Grup warna + Library Pustaka + English English + List Folder Daftar folder + Area Type Options Opsi teks area + Reshape Type Area Bentuk ulang area teks + Scale Skala + Feet & Inches Kaki & inci + Pixels Piksel + Pixel Piksel + More Selengkapnya + Show Tampilkan + Copy Width Point Salin titik lebar + Duplicate Artboard Duplikatkan artboard + Area / Path Type Teks area / teks jalur + Decrease Font Size Perkecil ukuran font + Increase Font Size Perbesar ukuran font + Step Type Langkah teks + Get Text Range Dapatkan rentang teks + Punctuation Tanda baca + [Normal Character Style] [Gaya karakter normal] + Text Wrap Options Opsi pembungkusan teks + Threads Utas + Orientation Orientasi + Set Text Atur teks + Insert Character Sisipkan karakter + Replace Ganti + Type on a Path Options Opsi teks pada jalur + Highlight Variable Sorot variabel + New Variable Variabel baru + List Variables Daftar variabel + Bind Variable Ikat variabel + New Data Set Set data baru + List Data Sets Daftar set data + Select Data Set Pilih set data + Edit Data Set Edit set data + Visibility Visibilitas + New View Tampilan baru + Saved Views Tampilan tersimpan + Magic Wand Options Opsi tongkat sihir + Magic Wand Settings Pengaturan tongkat sihir + Delete Save for Web Preset Hapus preset simpan untuk web + Save for Web Simpan untuk web + Save for Web Presets Preset simpan untuk web + GIF, 128 colours GIF, 128 warna + Save for Web Preview Pratinjau simpan untuk web + Save for Web Settings Pengaturan simpan untuk web + Save Save for Web Preset Simpan preset simpan untuk web + Eyedropper Options Opsi pipet + Free Distort Distorsi bebas + Move Artboard Pindahkan artboard + Sample Color Sampel warna + Angle Sudut + Black Hitam + Bottom Bawah + Bounds Batas + Colors Warna + Cyan Sian + Discard Buang + Document Dokumen + Encoding Pengodean + Frequency Frekuensi + Height Tinggi + Image Gambar + Ink Tinta + Lab Lab + Medium Sedang + Name Nama + None Nihil + Normal Normal + Pages Halaman + Regular Reguler + Source Sans 3 Source Sans 3 + Template Templat + Top Atas + Width Lebar + : : +effect Regular Reguler + Saving Poster.vectorcraft Menyimpan Poster.vectorcraft + Community Komunitas + Hide Bar Sembunyikan bilah + More Options Opsi lainnya + Show Properties Panel Tampilkan panel properti + Isolate Isolasi + Lock (⌘2) Kunci (⌘2) + New file File baru + Open Buka + Quickly start a new file Mulai file baru dengan cepat + Recent Files File terkini + Vector illustration — fast, open, scriptable. Ilustrasi vektor — cepat, terbuka, mendukung skrip. + Welcome to VectorCraft Selamat datang di VectorCraft + ArtCraft website Situs web ArtCraft + Discord Discord + Join our Discord Gabung ke Discord kami + Join the ArtCraft community on Discord (discord.gg/artcraft) Bergabunglah dengan komunitas ArtCraft di Discord (discord.gg/artcraft) + Source code on GitHub Kode sumber di GitHub + All Tools Semua alat + Draw Menggambar + Reset Atur ulang + — missing — hilang + (closest) (terdekat) + Change Ubah + Change All Ubah semua + Find Temukan + Find Font Temukan font + Fonts in Document: {count} Font dalam dokumen: {count} + In the selected objects Di objek yang dipilih + Find in Folder… Temukan di folder… + Replace With Font Ganti dengan font + — substituted by {style} — digantikan oleh {style} + — {n} characters from another font — {n} karakter dari font lain + Drag to float the panel group Seret untuk mengapungkan grup panel + Put back in the dock Kembalikan ke dok + All Scripts Semua skrip + Any Kind Jenis apa pun + Decorative Dekoratif + Favorites Favorit + Latin Latin + Thai Thai + Monospaced Monospace + No matching fonts Tidak ada font yang cocok + Rounded Membulat + Sans Serif / Gothic Sans Serif / Gothic + Script / Brush Script / Brush + Serif / Mincho Serif / Mincho + Changes made since it was last saved will be lost. Perubahan yang dibuat sejak terakhir kali disimpan akan hilang. + Can't open “{name}”. Tidak dapat membuka “{name}”. + Can't save “{name}”. Tidak dapat menyimpan “{name}”. + Can't export “{name}”. Tidak dapat mengekspor “{name}”. + Downloaded {what} Berhasil mengunduh {what} + Exported {count} file(s) to {folder} Berhasil mengekspor {count} file ke {folder} + Exported {what} Berhasil mengekspor {what} + Exported {what} with {} note(s): {first} Berhasil mengekspor {what} dengan {} catatan: {first} + Exporting {} Mengekspor {} + Imported {what}: {} Berhasil mengimpor {what}: {} + Installed plug-in {} Berhasil memasang plug-in {} + Opened with {} note(s): {} Dibuka dengan {} catatan: {} + Revert to the saved version of “{name}”? Kembalikan ke versi tersimpan dari “{name}”? + Replace “{name}”, the file this document was opened from? Ganti “{name}”, file asal dokumen ini? + Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Saat membukanya, VectorCraft meninggalkan bagian yang belum dapat dibacanya ({what}), sehingga mengganti file akan membuat bagian itu hilang permanen. Simpan dengan nama lain untuk mempertahankannya. + Saved {path} Berhasil menyimpan {path} + Saved {path} with {} note(s): {first} Berhasil menyimpan {path} dengan {} catatan: {first} + Saving Menyimpan + Saving {} Menyimpan {} + Search commands and tools… Cari perintah dan alat… + Dashed Line Garis putus-putus + Panel menu Menu panel + Unembed… Lepaskan sematan… + Applies the next time VectorCraft starts. Berlaku saat VectorCraft dimulai berikutnya. + Enable Contextual Task Bar Aktifkan bilah tugas kontekstual + Next Berikutnya + OK OK + Preferences Preferensi + Previous Sebelumnya + Restore every preference to its default (applied on OK) Kembalikan semua preferensi ke defaultnya (diterapkan saat OK) + Smaller ↔ Larger Lebih kecil ↔ Lebih besar + Smart Guides (View → Smart Guides) Panduan pintar (Tampilan → Panduan pintar) + Couldn't save recovery data: {e} Tidak dapat menyimpan data pemulihan: {e} + Discarded {} recovered document(s) Berhasil membuang {} dokumen yang dipulihkan + Restored {} recovered document(s): save them to keep them Berhasil membuka kembali {} dokumen yang dipulihkan: simpan agar tidak hilang + Saved recovery data for {title} Berhasil menyimpan data pemulihan untuk {title} + Saving recovery data for {} Menyimpan data pemulihan untuk {} + Alt Alt + Backspace Backspace + Cmd Cmd + Ctrl Ctrl + Shift Shift + Couldn't copy to the system clipboard: {e} Tidak dapat menyalin ke papan klip sistem: {e} + Couldn't paste from the clipboard: {e} Tidak dapat menempel dari papan klip: {e} + Channel Saluran + Circle Lingkaran + Clip to Art Pangkas ke karya seni + Colorize Warnai + Gamma Gamma + Hue Rona + Input Black Hitam masukan + Keep Original Pertahankan asli + Lightness Kecerahan + Mono Mono + Output White Putih keluaran + Saturation Saturasi + Screens Layar + Add Swatches Tambahkan sampel + Apply to All Terapkan ke semua + Swatch Conflict Konflik sampel + Axis: Sumbu: + Free Bebas + Keep: Pertahankan: + Search Cari + Classic Defaults Default klasik + Uniform Seragam + Include Blacks with CMY: Sertakan hitam dengan CMY: + Recolor Warnai ulang + Swatch Tiles Ubin sampel + Baseline Garis dasar + Flip: Balik: + Spacing: Spasi: + Output Keluaran + Export… Ekspor… + Import… Impor… + Load Profile… Muat profil… + Embedded Tersemat + Image Tracing Penelusuran gambar + Linked File File tertaut + Preset: Preset: + View: Tampilan: + Tracing Result Hasil penelusuran + Outlines with Source Image Kontur dengan gambar sumber + SVG Options Opsi SVG + Maximize Maksimalkan + Restore Pulihkan + Align Objects: Sejajarkan objek: + Default Fill and Stroke (D) Isi dan goresan default (D) + Fill (X), double-click for the Color Picker Isi (X), klik ganda untuk pemilih warna + Opacity: Opasitas: + Shape Modes: Mode bentuk: + Stroke (X), double-click for the Color Picker Goresan (X), klik ganda untuk pemilih warna + Stroke: Goresan: + Swap Fill and Stroke (Shift+X) Tukar isi dan goresan (Shift+X) + Manage Workspaces Kelola ruang kerja + Name: Nama: + New Baru + New Workspace Ruang kerja baru + Rename Ganti nama + Save the current layout under this name Simpan tata letak saat ini dengan nama ini + Saves the current bars, toolbar and panel layout. Menyimpan bilah, bilah alat, dan tata letak panel saat ini. + Workspace {i} Ruang kerja {i} + Glowing Edges Tepi berpendar + Edge Width Lebar tepi + Edge Brightness Kecerahan tepi + Smoothness Kehalusan + Color Halftone Halftone warna + Mezzotint Mezzotint + Pointillize Pointillisme + Max. Radius Radius maks. + Channel 1 Saluran 1 + Channel 2 Saluran 2 + Channel 3 Saluran 3 + Channel 4 Saluran 4 + Cell Size Ukuran sel + Radial Blur Pengaburan radial + Smart Blur Pengaburan pintar + Unsharp Mask Unsharp Mask + Gaussian Blur Pengaburan Gaussian + Method Metode + Quality Kualitas + Radius Radius + Apply Character Style Terapkan gaya karakter + Apply Paragraph Style Terapkan gaya paragraf + Character Style Options Opsi gaya karakter + Flip Balik + Couldn't {} {name}: {e} Tidak dapat {} {name}: {e} + Create PDF layers from top-level layers Buat lapisan PDF dari lapisan tingkat atas + Delete Character Style Hapus gaya karakter + Delete Layer Hapus lapisan + Delete Paragraph Style Hapus gaya paragraf + Duplicate Character Style Duplikatkan gaya karakter + Duplicate Layer Duplikatkan lapisan + Duplicate Paragraph Style Duplikatkan gaya paragraf + Spacing Spasi + Mojikumi Mojikumi + N pt per N pt per + New Character Style Gaya karakter baru + New Paragraph Style Gaya paragraf baru + Paragraph Style Options Opsi gaya paragraf + Redefine Character Style Definisikan ulang gaya karakter + Redefine Paragraph Style Definisikan ulang gaya paragraf + Rename Character Style Ganti nama gaya karakter + Rename Paragraph Style Ganti nama gaya paragraf + Select All Art on Layer Pilih semua karya seni pada lapisan + Settings… Pengaturan… + Joins Sambungan + Joins: Sambungan: + Distance Jarak + Distance: Jarak: + Gutter Celah + Gutter: Celah: + Length Panjang + Length: Panjang: + Points: Titik: + Scale X Skala X + Scale X: Skala X: + Scale Y Skala Y + Scale Y: Skala Y: + Shear: Geser: + Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required. Seret karya seni, warna, dan gaya teks ke sini untuk digunakan kembali di berbagai dokumen. Pustaka disimpan di komputer ini — tanpa perlu akun. + De-Interlace De-Interlace + Eliminate Eliminasi + Create New Fields by Buat bidang baru berdasarkan + Craquelure Craquelure + Grain Butiran + Mosaic Tiles Ubin mosaik + Patchwork Patchwork + Stained Glass Kaca patri + Texturizer Texturizer + Crack Spacing Jarak retakan + Crack Depth Kedalaman retakan + Crack Brightness Kecerahan retakan + Grain Type Jenis butiran + Tile Size Ukuran ubin + Grout Width Lebar nat + Lighten Grout Terangkan nat + Square Size Ukuran persegi + Relief Relief + Border Thickness Ketebalan tepi + Light Intensity Intensitas cahaya + Light Direction Arah cahaya + Value Axis Sumbu nilai + Diffuse Glow Pendaran difus + Glass Kaca + Ocean Ripple Riak samudra + Graininess Kebutiran + Glow Amount Jumlah pendaran + Clear Amount Jumlah pembersihan + Ripple Size Ukuran riak + Ripple Magnitude Besaran riak + Accented Edges Tepi beraksen + Angled Strokes Sapuan bersudut + Dark Strokes Sapuan gelap + Ink Outlines Kontur tinta + Spatter Cipratan + Sprayed Strokes Sapuan semprot + Sumi-e Sumi-e + Direction Balance Keseimbangan arah + Stroke Length Panjang sapuan + Sharpness Ketajaman + Strength Kekuatan + Balance Keseimbangan + Black Intensity Intensitas hitam + White Intensity Intensitas putih + Dark Intensity Intensitas gelap + Spray Radius Radius semprot + Stroke Direction Arah sapuan + Stroke Width Lebar sapuan + Stroke Pressure Tekanan sapuan +imageTrace Outline Kontur +@plural {n} item|{n} items {n} item +@plural Color Table ({n} color)|Color Table ({n} colors) Tabel warna ({n} warna) +@plural Delete this {n} graphic style?|Delete these {n} graphic styles? Hapus {n} gaya grafis ini? +@plural {n} Layer|{n} Layers {n} lapisan +@plural {n} Object|{n} Objects {n} objek +@plural {n} area highlighted|{n} areas highlighted {n} area disorot +@plural {n} color|{n} colors {n} warna +@plural {n} fill|{n} fills {n} isi +@plural {n} glyph|{n} glyphs {n} glif +@plural {n} object highlighted|{n} objects highlighted {n} objek disorot +@plural {n} star|{n} stars {n} bintang +@plural {n} step|{n} steps {n} langkah +@plural {n} stroke|{n} strokes {n} goresan +@msg no library `{id}` tidak ada pustaka `{id}` +@msg select some type first pilih beberapa teks terlebih dahulu +@msg {_1} has no {_2} style {_1} tidak memiliki gaya {_2} +@msg a layer couldn't be read satu lapisan tidak dapat dibaca +@msg it has more than one artboard memiliki lebih dari satu artboard +@msg it has no page tidak memiliki halaman +@msg it has too many text objects memiliki terlalu banyak objek teks +@msg it has too much text memiliki terlalu banyak teks +@msg its editing data doesn't say where its artboard is data penyuntingannya tidak menyebut di mana artboard-nya berada +@msg its editing data has no layers data penyuntingannya tidak memiliki lapisan +@msg its editing data is damaged data penyuntingannya rusak +@msg its editing data is for a different page data penyuntingannya untuk halaman yang berbeda +@msg its layers use {_1}, which aren't read yet lapisannya menggunakan {_1}, yang belum dapat dibaca +@msg its page has text, and its layers have no text objects to put it in halamannya memiliki teks, tetapi lapisannya tidak memiliki objek teks untuk menampungnya +@msg its type is to come in as outlines, which only its page has teksnya akan masuk sebagai kontur, yang hanya ada di halamannya +@msg the art on them differs from the page's in {_1}% of it karya seni pada lapisan tersebut berbeda dari milik halaman pada {_1}% bagiannya +@msg the art on them differs from the page's in {_1}% of it: they come from its editing data all the same karya seni pada lapisan tersebut berbeda dari milik halaman pada {_1}% bagiannya: lapisan tersebut tetap berasal dari data penyuntingannya +@msg `to` must be "corner" or "smooth" `to` harus "corner" atau "smooth" +@msg BMP files are RGB or grayscale: colorModel rgb or gray File BMP adalah RGB atau skala abu-abu: colorModel rgb atau gray +@msg Blend colors: select at least three objects with filled colours Campurkan warna: pilih setidaknya tiga objek dengan isian warna +@msg Combine Slices: the slices have no bounds Gabungkan irisan: irisan tidak memiliki batas +@msg Convert to Artboards: selection has no area Konversi ke artboard: seleksi tidak memiliki area +@msg Convert to Shape: no rectangles or ellipses selected Konversi ke bentuk: tidak ada persegi panjang atau elips yang dipilih +@msg Copied the CSS of {rules} object(s) Berhasil menyalin CSS dari {rules} objek +@msg Copied the script report Berhasil menyalin laporan skrip +@msg Corners edits the corners of a path Corners menyunting sudut jalur +@msg Couldn't copy to the system clipboard: {e} Tidak dapat menyalin ke papan klip sistem: {e} +@msg Couldn't open {name}: {e} Tidak dapat membuka {name}: {e} +@msg Couldn't place {name}: the document it was dropped on was closed Tidak dapat menempatkan {name}: dokumen tempatnya dijatuhkan sudah ditutup +@msg Couldn't read {name}: {e} Tidak dapat membaca {name}: {e} +@msg Couldn't save recovery data: {e} Tidak dapat menyimpan data pemulihan: {e} +@msg Create Outlines: select text objects Buat kontur: pilih objek teks +@msg Create from Guides: the document has no artboard Buat dari panduan: dokumen tidak memiliki artboard +@msg Create from Guides: the guides make more than {MAX_PIECES} slices Buat dari panduan: panduan menghasilkan lebih dari {MAX_PIECES} irisan +@msg Create from Selection: the selection has no area Buat dari seleksi: seleksi tidak memiliki area +@msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Pemulihan Data tidak punya tempat menyimpan salinan: atur Preferensi → Penanganan File → Pemulihan Data → Folder (recoveryFolder) +@msg direct-select anchor points to cut at (not the end points of open paths) pilih langsung titik jangkar yang akan dipotong (bukan titik ujung jalur terbuka) +@msg Divide Objects Below: select a path Bagi objek di bawah: pilih satu jalur +@msg Divide Objects Below: select exactly one path Bagi objek di bawah: pilih tepat satu jalur +@msg Downloaded {_1} file(s) Berhasil mengunduh {_1} file +@msg Downloaded {what} Berhasil mengunduh {what} +@msg Expand Appearance: the selection has a basic appearance Perluas tampilan: seleksi memiliki tampilan dasar +@msg Expand: no brush strokes selected Perluas: tidak ada sapuan kuas yang dipilih +@msg Expand: no gradient fills Perluas: tidak ada isian gradien +@msg Expand: nothing to expand Perluas: tidak ada yang dapat diperluas +@msg Exported {count} file(s) to {folder} Berhasil mengekspor {count} file ke {folder} +@msg Exported {path} Berhasil mengekspor {path} +@msg Flatten Transparency: select objects to flatten Ratakan transparansi: pilih objek yang akan diratakan +@msg Flatten Transparency: {e} Ratakan transparansi: {e} +@msg Image Trace: the traced image is missing Penelusuran gambar: gambar yang ditelusuri tidak ada +@msg Imported {what}: {_1} Berhasil mengimpor {what}: {_1} +@msg Installed plug-in {_1} Berhasil memasang plug-in {_1} +@msg Internal error: {msg} (please report this bug) Kesalahan internal: {msg} (harap laporkan bug ini) +@msg Live Paint Merge: select a Live Paint group and the paths to add Gabungkan Live Paint: pilih grup Live Paint dan jalur yang akan ditambahkan +@msg Live Paint: select paths or compound paths Live Paint: pilih jalur atau jalur majemuk +@msg Live Paint: these paths are too degenerate to paint Live Paint: jalur ini terlalu terdegenerasi untuk dicat +@msg Make Guides: select paths Buat panduan: pilih jalur +@msg No saved selections (Select → Save Selection…) Tidak ada seleksi tersimpan (Seleksi → Simpan Seleksi…) +@msg No saved views (View → New View…) Tidak ada tampilan tersimpan (Tampilan → Tampilan Baru…) +@msg Offset Path: select paths Jalur offset: pilih jalur +@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Jalur offset: offset tidak menyisakan apa pun dari jalur yang dipilih (inset yang lebih besar daripada bentuknya) +@msg Opened with {_1} note(s): {_2} Dibuka dengan {_1} catatan: {_2} +@msg Opened {url} Berhasil membuka {url} +@msg Outline Stroke: no stroked paths selected Ubah goresan menjadi kontur: tidak ada jalur dengan goresan yang dipilih +@msg PDF settings are not an object Pengaturan PDF bukan objek +@msg PDF writer error: {0} Kesalahan penulis PDF: {0} +@msg Packaged {n} file(s) in {folder}{note} Berhasil mengemas {n} file di {folder}{note} +@msg Packaged {n} file(s){note} Berhasil mengemas {n} file{note} +@msg Preview in Browser needs the desktop app (the web downloads files instead) Pratinjau di Peramban memerlukan aplikasi desktop (versi web mengunduh file sebagai gantinya) +@msg Release Guides: no guides Lepaskan panduan: tidak ada panduan +@msg Release: no layer to put the rectangle in Lepaskan: tidak ada lapisan untuk menaruh persegi panjangnya +@msg Replace Spine: select a blend and a path Ganti rangka: pilih satu blend dan satu jalur +@msg select two or more objects to distribute pilih dua objek atau lebih untuk didistribusikan +@msg select paths or anchor points pilih jalur atau titik jangkar +@msg SVG parse error: {0} Kesalahan penguraian SVG: {0} +@msg Save for Web: the image is empty Simpan untuk web: gambarnya kosong +@msg Save writes {_1}, not {_2}: use document.export for other formats Save menulis {_1}, bukan {_2}: gunakan document.export untuk format lain +@msg Saved recovery data for {title} Berhasil menyimpan data pemulihan untuk {title} +@msg Saved to User Defined: {_1} Tersimpan ke Buatan Pengguna: {_1} +@msg Saved {path} Berhasil menyimpan {path} +@msg Select text or click into text with the Type tool to insert glyphs Pilih teks atau klik di dalam teks dengan alat Teks untuk menyisipkan glif +@msg Shape Builder: no region under the pointer Pembangun Bentuk: tidak ada area di bawah penunjuk +@msg Shape Builder: select paths or compound paths Pembangun Bentuk: pilih jalur atau jalur majemuk +@msg Show All Hidden Attributes: nothing is hidden Tampilkan semua atribut tersembunyi: tidak ada yang tersembunyi +@msg Split Into Grid: select paths large enough for the grid Bagi menjadi kisi: pilih jalur yang cukup besar untuk kisi +@msg Targa files are RGB: colorModel rgb File Targa adalah RGB: colorModel rgb +@msg Text Wrap: select a wrap object Pembungkusan teks: pilih objek pembungkus +@msg `aspect` must be a number (%) `aspect` harus berupa angka (%) +@msg `at` must be [x, y] `at` harus [x, y] +@msg `color` must be a colour `color` harus berupa warna +@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` harus berupa perintah yang item menunya membuka dialog (lihat ui.menuDialog) +@msg `freeform.points` must be an array `freeform.points` harus berupa larik +@msg `freeform.lines` must be an array `freeform.lines` harus berupa larik +@msg `gradient` must be an object `gradient` harus berupa objek +@msg `library` is a library id or name (see {list}) `library` adalah id atau nama pustaka (lihat {list}) +@msg `mode` applies to freeform gradients `mode` berlaku untuk gradien bentuk bebas +@msg `sampleSize` must be 1, 3 or 5 `sampleSize` harus 1, 3, atau 5 +@msg `stops` must be an array `stops` harus berupa larik +@msg `svg` must be an object of SVG options (see document.formats) `svg` harus berupa objek opsi SVG (lihat document.formats) +@msg `{_1}` is no save command `{_1}` bukan perintah simpan +@msg `{area}` is no recovery area name `{area}` bukan nama area pemulihan +@msg `{color}` is not a colour (#rrggbb or a preset name) `{color}` bukan warna (#rrggbb atau nama preset) +@msg `{id}` has no envelope dialog `{id}` tidak memiliki dialog amplop +@msg `{keep}` is not a side of this axis `{keep}` bukan sisi dari sumbu ini +@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` harus `auto` atau kode bahasa seperti `en` atau `zh-hant` +@msg `{key}` must be a #rrggbb colour `{key}` harus berupa warna #rrggbb +@msg `{key}` must be a number `{key}` harus berupa angka +@msg `{key}` must be a string `{key}` harus berupa string +@msg `{key}` must be a whole number `{key}` harus berupa bilangan bulat +@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 `{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 +@msg `{name}` is a built-in workspace; choose another name `{name}` adalah ruang kerja bawaan; pilih nama lain +@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` adalah warna proses: tint berlaku untuk warna global dan spot +@msg `{name}` is no recovery entry name `{name}` bukan nama entri pemulihan +@msg `{name}` isn't a colour swatch `{name}` bukan sampel warna +@msg `{path}` must be a bool or an object of bools `{path}` harus berupa bool atau objek bool +@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` bukan warna (#rrggbb atau nama preset) +@msg `{tool}` isn't a Liquify tool `{tool}` bukan alat Liquify +@msg `{tool}` isn't a freehand tool `{tool}` bukan alat gambar bebas +@msg `{to}` is not an available name `{to}` bukan nama yang tersedia +@msg a PostScript error ({name} in `{op}`) kesalahan PostScript ({name} dalam `{op}`) +@msg a PostScript error ({name} in `{op}`), in {procs} kesalahan PostScript ({name} dalam `{op}`), dalam {procs} +@msg a PostScript error ({name} in `{op}`: {at}) kesalahan PostScript ({name} dalam `{op}`: {at}) +@msg a PostScript error ({name} in `{op}`: {at}), in {procs} kesalahan PostScript ({name} dalam `{op}`: {at}), dalam {procs} +@msg a PostScript error ({name}) kesalahan PostScript ({name}) +@msg a bitmap is empty bitmap kosong +@msg a bitmap is too large to read bitmap terlalu besar untuk dibaca +@msg a colour index is 0 to {_1}, not {n} indeks warna 0 hingga {_1}, bukan {n} +@msg a colour is an index, #rrggbb or a preset name, not {v} warna adalah indeks, #rrggbb, atau nama preset, bukan {v} +@msg a document needs at least one artboard dokumen memerlukan setidaknya satu artboard +@msg a document needs at least one layer dokumen memerlukan setidaknya satu lapisan +@msg a freeform gradient keeps at least one point gradien bentuk bebas mempertahankan setidaknya satu titik +@msg a gradient needs at least two stops gradien memerlukan setidaknya dua titik henti +@msg a graphic style named `{to}` already exists gaya grafis bernama `{to}` sudah ada +@msg a page is missing satu halaman tidak ada +@msg a page out of range halaman di luar rentang +@msg a page tree kid isn't a reference anak pohon halaman bukan referensi +@msg a page tree node has no kids simpul pohon halaman tidak memiliki anak +@msg a page's placement is not finite penempatan halaman bukan bilangan hingga +@msg a preset named `{_1}` exists preset bernama `{_1}` sudah ada +@msg a slice size must be a positive number of points ukuran irisan harus berupa bilangan positif dalam titik +@msg a stream runs past the end aliran melewati akhir +@msg a stream's length is wrong panjang aliran salah +@msg a stream's length isn't a number panjang aliran bukan angka +@msg add a format to export tambahkan format untuk diekspor +@msg already editing a pattern sedang menyunting pola +@msg an object can't be read objek tidak dapat dibaca +@msg an object in perspective would leave the edited grid objek dalam perspektif akan keluar dari kisi yang sedang disunting +@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art, atau type +@msg artboard {0} does not exist artboard {0} tidak ada +@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard pada skala ini gambar berukuran {w} × {h} pt, melebihi kapasitas kanvas: pilih skala yang lebih kecil atau paskan ke artboard +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` sumbu harus "vertical", "horizontal", atau sudut dalam derajat, bukan `{v}` +@msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}: transparan, putih, hitam, atau warna seperti "#ff8800" +@msg bad color {v} warna tidak valid {v} +@msg bad colour `{hex}` (#rrggbb) warna tidak valid `{hex}` (#rrggbb) +@msg bad page tree pohon halaman tidak valid +@msg bad shortcut pintasan tidak valid +@msg bad stream length panjang aliran tidak valid +@msg bad trailer trailer tidak valid +@msg balance must be between 0 and 100 balance harus di antara 0 dan 100 +@msg batch step {_1} (`{id}`) failed: {e} langkah batch {_1} (`{id}`) gagal: {e} +@msg brightness must be dark|mediumDark|mediumLight|light brightness harus dark|mediumDark|mediumLight|light +@msg can't encode the document: {0} tidak dapat mengodekan dokumen: {0} +@msg can't parse shortcut `{c}` tidak dapat mengurai pintasan `{c}` +@msg can't parse shortcut `{s}` for `{k}` tidak dapat mengurai pintasan `{s}` untuk `{k}` +@msg can't save version {0}: VectorCraft writes versions {MIN_VERSION} to {VERSION} tidak dapat menyimpan versi {0}: VectorCraft menulis versi {MIN_VERSION} hingga {VERSION} +@msg can't write .{ext} images tidak dapat menulis gambar .{ext} +@msg cannot distort a zero-size bounding box tidak dapat mendistorsi kotak pembatas berukuran nol +@msg cannot read PDF: {0} tidak dapat membaca PDF: {0} +@msg cannot split an open path at its end point tidak dapat membelah jalur terbuka pada titik ujungnya +@msg closing needs three anchors penutupan memerlukan tiga jangkar +@msg check an asset to export centang aset yang akan diekspor +@msg choice must be "merge" or "add", not `{choice}` choice harus "merge" atau "add", bukan `{choice}` +@msg choose a direction to divide in pilih arah untuk membagi +@msg choose a folder to export to pilih folder tujuan ekspor +@msg choose a location for the package pilih lokasi untuk paket +@msg clipboard is empty papan klip kosong +@msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` bukan warna seperti "#ff8800" +@msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: maksimal {MAX_TABLE} warna +@msg command `{0}` is not available right now: {1} perintah `{0}` tidak tersedia saat ini: {1} +@msg compound paths can only contain paths jalur majemuk hanya dapat berisi jalur +@msg compressed (RLE) bitmaps can't be read yet bitmap terkompresi (RLE) belum dapat dibaca +@msg damaged rusak +@msg decimals must be {_1}–{_2} (got {_3}) decimals harus {_1}–{_2} (diperoleh {_3}) +@msg divide into 1 to {MAX_PIECES} slices bagi menjadi 1 hingga {MAX_PIECES} irisan +@msg effect {k} is `{_1}`, not `{id}` efek {k} adalah `{_1}`, bukan `{id}` +@msg empty brush stroke sapuan kuas kosong +@msg empty cutter pemotong kosong +@msg empty path jalur kosong +@msg file version {0} is newer than this VectorCraft supports ({VERSION}) versi file {0} lebih baru daripada yang didukung VectorCraft ini ({VERSION}) +@msg freeform point {i} needs `at` [x, y] and a valid `color` titik bentuk bebas {i} memerlukan `at` [x, y] dan `color` yang valid +@msg freeform line {i} must be an array of point indices garis bentuk bebas {i} harus berupa larik indeks titik +@msg give both `start` and `end` [x, y] (or neither) berikan `start` dan `end` [x, y] keduanya (atau tidak sama sekali) +@msg give corners of the path: anchors where its sides meet at an angle berikan sudut jalur: jangkar tempat sisi-sisinya bertemu membentuk sudut +@msg give horizontal or vertical berikan horizontal atau vertikal +@msg give the path `id` and the width point `index` berikan jalur `id` dan titik lebar `index` +@msg guides are locked panduan terkunci +@msg image '{key}' has no pixels and was left out gambar '{key}' tidak memiliki piksel dan dilewati +@msg image data `{_1}` is missing data gambar `{_1}` tidak ada +@msg image would be too large; lower the resolution gambar akan menjadi terlalu besar; turunkan resolusinya +@msg index must be a number or first, previous, next or last index harus berupa angka atau first, previous, next, atau last +@msg index must be a number or first, previous, next or last, not `{other}` index harus berupa angka atau first, previous, next, atau last, bukan `{other}` +@msg installing from a path is not available on the web; pass the module as `dataBase64` pemasangan dari jalur tidak tersedia di web; berikan modul sebagai `dataBase64` +@msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) kesalahan internal di `{cmd}`: {msg} (dokumen dibiarkan seperti sebelumnya; harap laporkan bug ini) +@msg internal error: {msg} (please report this bug) kesalahan internal: {msg} (harap laporkan bug ini) +@msg invalid PDF setting: {0} pengaturan PDF tidak valid: {0} +@msg invalid image data for `{0}` data gambar tidak valid untuk `{0}` +@msg invalid parameters for `{cmd}`: {msg} parameter tidak valid untuk `{cmd}`: {msg} +@msg is not an image VectorCraft reads bukan gambar yang dapat dibaca VectorCraft +@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know menggunakan `{at}`, yang tidak dikenali pembaca PostScript VectorCraft +@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know, in {procs} menggunakan `{at}`, yang tidak dikenali pembaca PostScript VectorCraft, dalam {procs} +@msg its Windows metafile preview was opened instead, which may simplify the art yang dibuka adalah pratinjau metafile Windows-nya, yang mungkin menyederhanakan karya seni +@msg its preview was placed instead, at screen resolution yang ditempatkan adalah pratinjaunya, pada resolusi layar +@msg join needs open paths penggabungan memerlukan jalur terbuka +@msg lang must be one of {_1} lang harus salah satu dari {_1} +@msg layers can't be wrap objects lapisan tidak dapat menjadi objek pembungkus +@msg missing `folder` `folder` tidak ada +@msg missing `id` (see plugin.list) `id` tidak ada (lihat plugin.list) +@msg missing `name` `name` tidak ada +@msg missing `tool` `tool` tidak ada +@msg missing index indeks tidak ada +@msg multiple windows land with M11.5 beberapa jendela hadir bersama M11.5 +@msg name the file beri nama file +@msg no CSS to copy: select objects with CSS of their own tidak ada CSS untuk disalin: pilih objek yang memiliki CSS sendiri +@msg no Live Paint edge near that point tidak ada tepi Live Paint di dekat titik itu +@msg no Live Paint face at that point tidak ada bidang Live Paint di titik itu +@msg no PNG encoder tidak ada pengode PNG +@msg no active document tidak ada dokumen aktif +@msg no active perspective plane tidak ada bidang perspektif aktif +@msg no anchor points selected tidak ada titik jangkar yang dipilih +@msg no app to open files here tidak ada aplikasi untuk membuka file di sini +@msg no artboard tidak ada artboard +@msg no artboard {i} (0-based; the document has {_1}) tidak ada artboard {i} (berbasis 0; dokumen memiliki {_1}) +@msg no artboard {i} (0-based; the document has {count}) tidak ada artboard {i} (berbasis 0; dokumen memiliki {count}) +@msg no artboards named tidak ada artboard bernama +@msg no artwork tidak ada karya seni +@msg no blend options tidak ada opsi blend +@msg no bounds tidak ada batas +@msg no brush named `{name}` tidak ada kuas bernama `{name}` +@msg no brush selected tidak ada kuas yang dipilih +@msg no catalog tidak ada katalog +@msg no changes since the last save tidak ada perubahan sejak penyimpanan terakhir +@msg no clock to keep a heartbeat by tidak ada jam untuk menjaga detak +@msg no colour group `{g}` tidak ada grup warna `{g}` +@msg no cross-reference table tidak ada tabel rujukan silang +@msg no current layer tidak ada lapisan saat ini +@msg no data tidak ada data +@msg no dialog open tidak ada dialog yang terbuka +@msg no document tidak ada dokumen +@msg no document open tidak ada dokumen yang terbuka +@msg no effect applied yet belum ada efek yang diterapkan +@msg no effect at index {k} tidak ada efek pada indeks {k} +@msg no end record tidak ada rekaman akhir +@msg no file manager here tidak ada pengelola file di sini +@msg no file reader tidak ada pembaca file +@msg no file system here tidak ada sistem file di sini +@msg no file writer tidak ada penulis berkas +@msg no files to place tidak ada berkas untuk ditempatkan +@msg no format to export to tidak ada format tujuan ekspor +@msg no free recovery area tidak ada area pemulihan yang kosong +@msg no gradient stop or freeform point is selected tidak ada perhentian gradien atau titik bentuk bebas yang dipilih +@msg no graphic style `{name}` tidak ada gaya grafis `{name}` +@msg no graphic style `{n}` tidak ada gaya grafis `{n}` +@msg no interaction in progress tidak ada interaksi yang berlangsung +@msg no key object tidak ada objek kunci +@msg no library `{key}` (see {list}) tidak ada pustaka `{key}` (lihat {list}) +@msg no native format tidak ada format asli +@msg no pages tidak ada halaman +@msg no pattern `{name}` tidak ada pola `{name}` +@msg no perspective grid preset named `{name}` tidak ada preset kisi perspektif bernama `{name}` +@msg no plug-in {0} is installed tidak ada plug-in {0} yang terpasang +@msg no plug-in {id} is installed tidak ada plug-in {id} yang terpasang +@msg no point {i} (the gradient has {_1}) tidak ada titik {i} (gradien memiliki {_1}) +@msg no preset editor tidak ada editor preset +@msg no previous transform tidak ada transformasi sebelumnya +@msg no print preset named `{base}` tidak ada preset cetak bernama `{base}` +@msg no print preset named `{name}` tidak ada preset cetak bernama `{name}` +@msg no print service tidak ada layanan cetak +@msg no revert command tidak ada perintah pengembalian +@msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) tidak ada preset PDF tersimpan bernama `{name}` (preset bawaan bersifat hanya-baca: buat yang baru dari preset tersebut) +@msg no saved presets tidak ada preset tersimpan +@msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) tidak ada preset cetak tersimpan bernama `{name}` ({DEFAULT_PRESET} dilindungi: buat yang baru darinya) +@msg no saved profiles tidak ada profil tersimpan +@msg no saved selections tidak ada seleksi tersimpan +@msg no saved themes tidak ada tema tersimpan +@msg no segment {segment} on line {line} tidak ada segmen {segment} pada baris {line} +@msg no selected slice in the image: select slices, or save all of them tidak ada slice yang dipilih dalam gambar: pilih slice, atau simpan semuanya +@msg no selection bounds tidak ada batas seleksi +@msg no slice selected tidak ada slice yang dipilih +@msg no such action tidak ada aksi seperti itu +@msg no such anchor tidak ada jangkar seperti itu +@msg no such artboard tidak ada artboard seperti itu +@msg no such document tidak ada dokumen seperti itu +@msg no such layer or object tidak ada lapisan atau objek seperti itu +@msg no such object {0} tidak ada objek {0} +@msg no such perspective grid preset tidak ada preset kisi perspektif seperti itu +@msg no such recent file tidak ada berkas terbaru seperti itu +@msg no such recent font tidak ada font terbaru seperti itu +@msg no such saved selection tidak ada seleksi tersimpan seperti itu +@msg no such segment tidak ada segmen seperti itu +@msg no such subpath tidak ada subjalur seperti itu +@msg no such user library tidak ada pustaka pengguna seperti itu +@msg no such view tidak ada tampilan seperti itu +@msg no such width point tidak ada titik lebar seperti itu +@msg no swatch `{name}` tidak ada swatch `{name}` +@msg no swatch library `{l}` (see swatch.library.list) tidak ada pustaka swatch `{l}` (lihat swatch.library.list) +@msg no symbol instances selected tidak ada instans simbol yang dipilih +@msg no target object has an opacity mask: select one or give `id` tidak ada objek target yang memiliki masker opasitas: pilih satu atau berikan `id` +@msg no threaded text selected tidak ada teks beruntun yang dipilih +@msg no user library folder here tidak ada folder pustaka pengguna di sini +@msg no user workspace `{from}` tidak ada ruang kerja pengguna `{from}` +@msg node {_1} is not text simpul {_1} bukan teks +@msg not a DXF drawing bukan gambar DXF +@msg not a VectorCraft file: {0} bukan berkas VectorCraft: {0} +@msg not a VectorCraft shortcut set (missing `overrides`) bukan set pintasan VectorCraft (tidak ada `overrides`) +@msg not a graphic style library (format `{_1}`) bukan pustaka gaya grafis (format `{_1}`) +@msg not a stored central record bukan rekaman pusat yang tersimpan +@msg not a valid plug-in module: {0} bukan modul plug-in yang valid: {0} +@msg not an EMF or WMF file bukan berkas EMF atau WMF +@msg not found tidak ditemukan +@msg not in pattern editing mode tidak dalam mode pengeditan pola +@msg nothing encoded tidak ada yang dikodekan +@msg nothing selected tidak ada yang dipilih +@msg nothing to align tidak ada yang dapat disejajarkan +@msg nothing to distort tidak ada yang dapat didistorsi +@msg nothing to export: the document has no visible art (or turn on Use Artboards) tidak ada yang dapat diekspor: dokumen tidak memiliki karya seni yang terlihat (atau aktifkan Gunakan Artboard) +@msg nothing to preview tidak ada yang dapat dipratinjau +@msg nothing to print tidak ada yang dapat dicetak +@msg nothing to redo tidak ada yang dapat dipulihkan +@msg nothing to save: the document has no visible art tidak ada yang dapat disimpan: dokumen tidak memiliki karya seni yang terlihat +@msg nothing to undo tidak ada yang dapat diurungkan +@msg nothing to warp tidak ada yang dapat diubah bentuknya +@msg nothing to wrap tidak ada yang dapat dibungkus +@msg object {0} cannot have children objek {0} tidak dapat memiliki anak +@msg object {id} is not a path objek {id} bukan jalur +@msg object {id} is not a selected top-level object objek {id} bukan objek tingkat atas yang dipilih +@msg page size {w} × {h} ukuran halaman {w} × {h} +@msg page {0} does not exist (the PDF has {1}) halaman {0} tidak ada (PDF memiliki {1}) +@msg paste unavailable tempel tidak tersedia +@msg path is already closed jalur sudah tertutup +@msg paths contain invalid coordinates jalur berisi koordinat yang tidak valid +@msg paths: give at least one file jalur: berikan setidaknya satu berkas +@msg pattern missing pola hilang +@msg patterns nested too deeply are filled with mid-grey pola yang bersarang terlalu dalam diisi dengan abu-abu tengah +@msg percent must be above 0 and at most 6400, not {p} persen harus di atas 0 dan paling banyak 6400, bukan {p} +@msg pick a colour, gradient or pattern to save as a swatch pilih warna, gradien, atau pola untuk disimpan sebagai swatch +@msg plane must be left, right or ground bidang harus kiri, kanan, atau tanah +@msg plug-in ABI: {0} ABI plug-in: {0} +@msg plug-in exceeded its {0} plug-in melampaui {0}-nya +@msg plug-in failed: {0} plug-in gagal: {0} +@msg plug-in manifest: {0} manifest plug-in: {0} +@msg plug-in output: {0} keluaran plug-in: {0} +@msg plug-in parameters: {0} parameter plug-in: {0} +@msg plug-in trapped: {0} plug-in terperangkap: {0} +@msg print settings are not an object pengaturan cetak bukan objek +@msg printing isn't available here pencetakan tidak tersedia di sini +@msg range `{s}` is empty rentang `{s}` kosong +@msg range `{s}`: `{part}` names no number rentang `{s}`: `{part}` tidak menyebutkan angka +@msg range `{s}`: `{part}` runs backwards rentang `{s}`: `{part}` berjalan mundur +@msg range `{s}`: `{t}` is not a number from 1 to {count} rentang `{s}`: `{t}` bukan angka dari 1 sampai {count} +@msg recovery copy `{file}`: {e} salinan pemulihan `{file}`: {e} +@msg resolutions must be between 1 and 2400 ppi resolusi harus di antara 1 dan 2400 ppi +@msg result would exceed the canvas (coordinates out of range) hasil akan melampaui kanvas (koordinat di luar rentang) +@msg objects can't be nested more than 32 deep objek tidak dapat bersarang lebih dari 32 tingkat +@msg the SVG's elements are nested more than {0} deep elemen SVG bersarang lebih dari {0} tingkat +@msg scale must be a number from {_1} to {_2}, not {_3} skala harus berupa angka dari {_1} sampai {_2}, bukan {_3} +@msg scale must be a number from {_1} to {_2}, not {s} skala harus berupa angka dari {_1} sampai {_2}, bukan {s} +@msg select (or target) objects whose appearance isn't basic pilih (atau jadikan target) objek yang penampilannya bukan dasar +@msg select a Live Paint group pilih grup Live Paint +@msg select a blend pilih blend +@msg select a clip group pilih grup klip +@msg select a gradient mesh pilih mesh gradien +@msg select a linked image: an embedded one has no file pilih gambar tertaut: gambar yang tersemat tidak memiliki berkas +@msg select a repeat pilih pengulangan +@msg select a stroke with a variable width pilih goresan dengan lebar variabel +@msg select a symbol instance pilih instans simbol +@msg select an Image Trace object pilih objek Image Trace +@msg could not decode image: {0} tidak dapat mendekode gambar: {0} +@msg image is empty gambar kosong +@msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) gambarnya {width} × {height} piksel, lebih besar dari yang diterima Image Trace ({max} megapiksel) +@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset penelusuran ini akan menghasilkan lebih dari {max} titik jangkar, lebih banyak dari yang dapat ditampilkan dokumen secara mulus: naikkan Derau, turunkan Jalur atau Warna, atau pilih preset ketelitian rendah +@msg select an envelope pilih amplop +@msg select an image pilih gambar +@msg select an object in perspective pilih objek dalam perspektif +@msg select artwork (or a colour group) to recolor pilih karya seni (atau grup warna) untuk diwarnai ulang +@msg select at least two objects pilih setidaknya dua objek +@msg select objects, not layers pilih objek, bukan lapisan +@msg select one path, or give its `id` pilih satu jalur, atau berikan `id`-nya +@msg select paths to join pilih jalur yang akan digabungkan +@msg select the art to repeat pilih karya seni yang akan diulang +@msg select the artwork to warp pilih karya seni yang akan diubah bentuknya +@msg select the objects to transform in perspective pilih objek yang akan ditransformasikan dalam perspektif +@msg select two or more graphic styles to merge pilih dua gaya grafis atau lebih untuk digabungkan +@msg select type in perspective pilih teks dalam perspektif +@msg selection has no bounds seleksi tidak memiliki batas +@msg shadings of an unknown type were left out shading dengan tipe tidak dikenal tidak disertakan +@msg shadings of more than 4096 patches or triangles were left out shading dengan lebih dari 4096 patch atau segitiga tidak disertakan +@msg shadings whose functions are of an unknown type are filled with their middle colour shading yang fungsinya bertipe tidak dikenal diisi dengan warna tengahnya +@msg shortcut for `{k}` must be a string pintasan untuk `{k}` harus berupa string +@msg stop {i} needs `offset` perhentian {i} membutuhkan `offset` +@msg stop {i} needs a valid `color` perhentian {i} membutuhkan `color` yang valid +@msg stop {i}: `{k}` must be a number perhentian {i}: `{k}` harus berupa angka +@msg the EMF header is damaged header EMF rusak +@msg the PDF has no pages PDF tidak memiliki halaman +@msg the PDF is no longer loaded PDF tidak lagi dimuat +@msg the PDF is password-protected: give its password PDF dilindungi kata sandi: berikan kata sandinya +@msg the PDF password is wrong kata sandi PDF salah +@msg the Save PDF dialog didn't open dialog Simpan PDF tidak terbuka +@msg the WMF header is damaged header WMF rusak +@msg the art could not be sampled karya seni tidak dapat diambil sampelnya +@msg the art has no bounds karya seni tidak memiliki batas +@msg the art is too light for any dots karya seni terlalu terang untuk titik apa pun +@msg the artboard to fit must be a positive size up to {_1} pt artboard yang akan dipaskan harus berukuran positif hingga {_1} pt +@msg the background worker stopped worker latar belakang telah berhenti +@msg the compressed file unpacks to more than {_1} MB berkas terkompresi ini terekstrak menjadi lebih dari {_1} MB +@msg the document has never been saved dokumen belum pernah disimpan +@msg the document has no artboard dokumen tidak memiliki artboard +@msg the document has no artboards dokumen tidak memiliki artboard +@msg the document has no patterns dokumen tidak memiliki pola +@msg the document has no slices dokumen tidak memiliki slice +@msg the document has no symbols dokumen tidak memiliki simbol +@msg the drawing has no layout `{_1}` (it has {_2}) gambar tidak memiliki tata letak `{_1}` (yang ada: {_2}) +@msg the drawing is no longer loaded gambar tidak lagi dimuat +@msg the drawing is too large to open gambar terlalu besar untuk dibuka +@msg the export region is empty area ekspor kosong +@msg the export region is not finite area ekspor tidak berhingga +@msg the export returned unreadable data ekspor menghasilkan data yang tidak dapat dibaca +@msg the export wrote no file ekspor tidak menulis berkas apa pun +@msg the file holds only a placeholder page, not its art (it was saved without PDF compatibility): save it again with PDF compatibility on, or as PDF or SVG berkas hanya memuat halaman placeholder, bukan karya seninya (disimpan tanpa kompatibilitas PDF): simpan lagi dengan kompatibilitas PDF aktif, atau sebagai PDF atau SVG +@msg the file was read up to an error ({why}): what follows it was left out berkas dibaca sampai terjadi kesalahan ({why}): bagian selanjutnya tidak disertakan +@msg the file's preview header points outside the file header pratinjau berkas menunjuk ke luar berkas +@msg the image's pixels don't match its size piksel gambar tidak cocok dengan ukurannya +@msg the layer has no objects to clip by lapisan tidak memiliki objek untuk dijadikan pemotong +@msg the layer or group is locked lapisan atau grup terkunci +@msg the guide is on a hidden or locked layer panduan berada di lapisan yang tersembunyi atau terkunci +@msg the target layer is locked or hidden lapisan target terkunci atau tersembunyi +@msg the layer to flatten into is hidden lapisan tujuan perataan tersembunyi +@msg the object already has an opacity mask objek sudah memiliki masker opasitas +@msg the object has no geometry objek tidak memiliki geometri +@msg the object has no opacity mask objek tidak memiliki masker opasitas +@msg the object has no stroke objek tidak memiliki goresan +@msg the object is beyond the plane's horizon objek berada di luar horizon bidang +@msg the object isn't in perspective objek tidak dalam perspektif +@msg the object isn't on a perspective plane objek tidak berada pada bidang perspektif +@msg the object would cross the horizon of the perspective plane objek akan melintasi horizon bidang perspektif +@msg the objects would leave the grid objek akan keluar dari kisi +@msg the package came back damaged paket kembali dalam keadaan rusak +@msg the pointer is beyond the plane's horizon penunjuk berada di luar horizon bidang +@msg the preview image is empty gambar pratinjau kosong +@msg the print job has no data pekerjaan cetak tidak memiliki data +@msg the scale is not a usable number skala bukan angka yang dapat digunakan +@msg the selected stroke's profile is already listed as {_1} profil goresan yang dipilih sudah terdaftar sebagai {_1} +@msg the selection has no URL (or they differ) seleksi tidak memiliki URL (atau URL-nya berbeda) +@msg the selection isn't in perspective seleksi tidak dalam perspektif +@msg the stroke has no weight goresan tidak memiliki ketebalan +@msg the thumbnail image is malformed gambar mini salah format +@msg the top object must be a path, compound path or text object to use as a clipping mask objek teratas harus berupa jalur, jalur majemuk, atau objek teks untuk digunakan sebagai masker kliping +@msg the {tool} tool has no options alat {tool} tidak memiliki opsi +@msg there are no objects to release tidak ada objek untuk dilepaskan +@msg there are no printer settings to open here tidak ada pengaturan pencetak yang bisa dibuka di sini +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it berkas PostScript ini tidak dapat dibaca ({why}): simpan sebagai PDF atau SVG di aplikasi yang membuatnya +@msg this file's PostScript couldn't be read ({why}): {what} PostScript berkas ini tidak dapat dibaca ({why}): {what} +@msg this format holds one artboard: export several as PDF or with document.exportForScreens format ini hanya memuat satu artboard: ekspor beberapa sekaligus sebagai PDF atau dengan document.exportForScreens +@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that ini adalah gambar DWG yang tidak dapat dibaca: simpan dari aplikasi CAD sebagai ASCII DXF, lalu buka berkas itu +@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG ini adalah berkas PostScript (.ai yang disimpan dalam format lama atau tanpa kompatibilitas PDF, atau berkas EPS), yang belum bisa dibuka: simpan sebagai PDF, .ai kompatibel PDF, atau SVG +@msg this is not a PostScript file ini bukan berkas PostScript +@msg tool shortcuts must be a single key (optionally with Shift) pintasan alat harus satu tombol (opsional dengan Shift) +@msg truncated terpotong +@msg unknown command `{0}` perintah `{0}` tidak dikenal +@msg unknown effect `{id}` efek `{id}` tidak dikenal +@msg unknown format format tidak dikenal +@msg unknown format `{f}` (Save writes {_1}) format `{f}` tidak dikenal (Simpan menulis {_1}) +@msg unknown freeform mode `{m}` (points, lines) mode bentuk bebas `{m}` tidak dikenal (titik, garis) +@msg unknown gradient kind `{k}` (linear, radial, freeform) jenis gradien `{k}` tidak dikenal (linear, radial, bentuk bebas) +@msg unknown highlight `{h}` sorotan `{h}` tidak dikenal +@msg unknown option `{path}.{k}` opsi `{path}.{k}` tidak dikenal +@msg unknown overprints `{o}` (preserve, simulate or discard) overprint `{o}` tidak dikenal (pertahankan, simulasikan, atau buang) +@msg unknown panel `{raw}` panel `{raw}` tidak dikenal +@msg unknown preference `{key}` preferensi `{key}` tidak dikenal +@msg unknown preset (one of {_1}) preset tidak dikenal (salah satu dari {_1}) +@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) preset `{name}` tidak dikenal (tinggi, sedang, rendah, atau yang tersimpan: lihat flattener.presets.list) +@msg unknown preset `{name}` (see flattener.presets.list) preset `{name}` tidak dikenal (lihat flattener.presets.list) +@msg unknown preset `{name}` (see webExport.presets.list) preset `{name}` tidak dikenal (lihat webExport.presets.list) +@msg unknown side `{other}` (left|right|top|bottom) sisi `{other}` tidak dikenal (kiri|kanan|atas|bawah) +@msg unknown tool `{tool}` alat `{tool}` tidak dikenal +@msg unknown tool `{t}` alat `{t}` tidak dikenal +@msg unknown tool or command `{key}` alat atau perintah `{key}` tidak dikenal +@msg unknown workspace (one of {_1}) ruang kerja tidak dikenal (salah satu dari {_1}) +@msg unsupported: {0} tidak didukung: {0} +@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files berkas versi {0} tidak dapat dikompresi: hanya versi {COMPRESSED_SINCE} dan sesudahnya yang dapat membuka berkas terkompresi +@msg width and height are at least 1 pixel lebar dan tinggi minimal 1 piksel +@msg workspace name can't be empty nama ruang kerja tidak boleh kosong +@msg {C}: no effect at index {from} {C}: tidak ada efek pada indeks {from} +@msg {_1} failed: {e} {_1} gagal: {e} +@msg {_1} files can be opened but not written (see document.formats) berkas {_1} dapat dibuka tetapi tidak dapat ditulis (lihat document.formats) +@msg {_1} font families available {_1} keluarga font tersedia +@msg {_1} is a live effect (Effect › Plug-ins) {_1} adalah efek langsung (Efek › Plug-in) +@msg {_1} is already used by {_2} {_1} sudah digunakan oleh {_2} +@msg {_1} linked file(s) can't be read here: their previews show {_1} berkas tertaut tidak dapat dibaca di sini: yang ditampilkan adalah pratinjaunya +@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} gambar tertaut di disk telah berubah: Perbarui Tautan menampilkan versi barunya +@msg {_1}: not saved, so not closed {_1}: tidak disimpan, sehingga tidak ditutup +@msg {bpp}-bit bitmaps can't be read bitmap {bpp}-bit tidak dapat dibaca +@msg {command} didn't create an object {command} tidak membuat objek +@msg {label} produced an empty result {label} memberikan hasil kosong +@msg {label}: lost track of a shape's style {label}: kehilangan jejak gaya sebuah bentuk +@msg {label}: select paths {label}: pilih jalur +@msg {label}: select paths, compound paths, groups or text {label}: pilih jalur, jalur majemuk, grup, atau teks +@msg {label}… {label}… +@msg {name}: CRC mismatch {name}: ketidakcocokan CRC +@msg {name}: no local header {name}: tidak ada header lokal +@msg {name}: no such recovery entry {name}: tidak ada entri pemulihan seperti itu +@msg {path} can't be found: relink it first {path} tidak dapat ditemukan: tautkan ulang dulu +@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: tidak ada sistem berkas di sini (berikan dataBase64 untuk membuka; hilangkan jalurnya untuk mendapatkan dataBase64) +@msg {path}: not a file {path}: bukan berkas +@msg {path}: {e} {path}: {e} +@msg {status} ({e}) {status} ({e}) +@msg Couldn't place {name}: {e} Tidak dapat menempatkan {name}: {e} +@msg Couldn't paste from the clipboard: {e} Tidak dapat menempel dari papan klip: {e} +@msg Saved {path} with {_1} note(s): {first} {path} disimpan dengan {_1} catatan: {first} +@msg Exported {what} with {_1} note(s): {first} {what} diekspor dengan {_1} catatan: {first} +@msg Exported {what} {what} diekspor +@msg Saved {n} files beside {path} {n} berkas disimpan di sebelah {path} +@msg Saving Menyimpan +@msg Saving {_1} Menyimpan {_1} +@msg Exporting {_1} Mengekspor {_1} +@msg Saving recovery data for {_1} Menyimpan data pemulihan untuk {_1} +@msg Discarded {_1} recovered document(s) {_1} dokumen hasil pemulihan dibuang +@msg Restored {_1} recovered document(s): save them to keep them {_1} dokumen hasil pemulihan dipulihkan: simpan agar tidak hilang +@msg Embedded {n} image(s); {missing} missing: relink them first {n} gambar tersemat; {missing} hilang: tautkan ulang dulu +@msg Embedded {n} image(s) {n} gambar tersemat +@msg Relinked {_1} image(s) {_1} gambar ditautkan ulang +@msg Relinked {n} image(s) {n} gambar ditautkan ulang +@msg Relinked {n} image(s); not in that folder: {_1} {n} gambar ditautkan ulang; tidak ada di folder tersebut: {_1} +@msg Updated {_1} image(s) {_1} gambar diperbarui +@msg Unembedded to {_1} Gambar tersemat dilepas ke {_1} +@msg Reset {_1} {_1} diatur ulang +@msg Sent to {_1} Dikirim ke {_1} +@msg {_1} files berkas {_1} +@msg The graphics were lost and have been restarted Grafis hilang dan telah dimulai ulang +@msg The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary} Prosesor grafis yang dicoba pertama kali tidak dapat menampilkan jendela, jadi VectorCraft memulai ulang dengan {summary} +@msg Loading VectorCraft… Memuat VectorCraft… +@msg VectorCraft failed to start: {e} VectorCraft gagal dimulai: {e} +@msg A browser with WebGPU or WebGL2 is required. Diperlukan browser dengan WebGPU atau WebGL2. +@msg The graphics were lost and couldn't be restarted. Grafis hilang dan tidak dapat dimulai ulang. +@msg Your unsaved changes are kept in this browser: reload the page to get them back. Perubahan Anda yang belum disimpan tetap ada di browser ini: muat ulang halaman untuk mengambilnya kembali. +@msg Reload the page to go on (changes not saved yet may be lost). Muat ulang halaman untuk melanjutkan (perubahan yang belum disimpan mungkin hilang). +@msg tool (a tool id) is required tool (id alat) diperlukan +@msg {tool} isn't in a toolbar group of several tools {tool} tidak berada dalam grup bilah alat berisi beberapa alat +@msg onto must name a panel floating in another group onto harus menyebutkan panel yang mengambang di grup lain +@msg below and above must name a panel floating in another group below dan above harus menyebutkan panel yang mengambang di grup lain +@msg give only one of onto, below and above berikan hanya satu dari onto, below, dan above +@msg column must be true or a number from 1 column harus true atau angka mulai dari 1 +@msg x and y must both be numbers x dan y harus keduanya angka +@msg "{0}" is not a colour: write it as #rrggbb "{0}" bukan warna: tulis sebagai #rrggbb +@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead ini bukan logo dengan warna rata ({_1}% area solidnya cocok dengan beberapa warna rata), jadi mode Logo Rata akan mengubah gradiennya menjadi patch: gunakan mode Warna sebagai gantinya +@msg `{what}` closes nothing `{what}` tidak menutup apa pun +@msg `{what}` closes something it didn't open `{what}` menutup sesuatu yang tidak dibukanya +@msg a path has too many points sebuah jalur memiliki terlalu banyak titik +@msg it ends inside a layer berakhir di dalam lapisan +@msg it has art outside its layers memiliki karya seni di luar lapisannya +@msg it has more than {MAX_NODES} objects memiliki lebih dari {MAX_NODES} objek +@msg it has no layers tidak memiliki lapisan +@msg it has too many values in a row memiliki terlalu banyak nilai berturut-turut +@msg it has {_1}, which VectorCraft doesn't read from it yet memiliki {_1}, yang belum dibaca VectorCraft dari berkas tersebut +@msg its objects are nested too deeply objeknya bersarang terlalu dalam +@msg its section ending with `%{end}` doesn't end bagian yang berakhir dengan `%{end}` tidak berakhir +@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objek dengan beberapa isian atau goresan, efek, atau kuas masuk sebagai tampilan gambarnya ({_1}): penampilannya belum dapat diedit +@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradien “{name}” tidak didefinisikan dalam berkas: warna polos dipakai sebagai pengganti +@msg an image VectorCraft can't read was left out gambar yang tidak dapat dibaca VectorCraft tidak disertakan +@msg an image whose data is cut short was left out gambar yang datanya terpotong tidak disertakan +@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel gambar tertaut menampilkan pratinjau resolusi rendah yang tersimpan dalam berkas sampai berkasnya ditemukan: tautkan ulang di panel Tautan +@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise teks area dalam bingkai yang terlalu pendek untuk satu baris font yang ditampilkannya mendapat bingkai yang lebih tinggi ({_1}): tanpa itu teksnya tidak akan tampil +@msg its editing data doesn't say where its art is data pengeditannya tidak menyatakan di mana karya seninya berada +@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) berkas ini disimpan tanpa kompatibilitas PDF, dan data pengeditannya tidak dapat dibaca ({why}) +@msg No fonts are missing in this document Tidak ada font yang hilang dalam dokumen ini +@msg VectorCraft doesn't search other apps' or the system's folders: {path} VectorCraft tidak mencari di folder aplikasi lain atau folder sistem: {path} +@msg VectorCraft doesn't search the Shared or Public folder in Users: {path} VectorCraft tidak mencari di folder Shared atau Public di Users: {path} +@msg {path} is not a folder {path} bukan folder +@msg the search couldn't start: try again in a moment pencarian tidak dapat dimulai: coba lagi sebentar lagi +@msg searching a folder isn't available on the web mencari di folder tidak tersedia di web +@msg every search thread stopped on an internal error (please report this bug) semua utas pencarian berhenti karena kesalahan internal (mohon laporkan bug ini) +@msg VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it folder Fonts VectorCraft tidak diatur di sini: aplikasi desktop dan vectorcraft-cli yang mengaturnya +@msg choose the font files to add pilih berkas font yang akan ditambahkan +@msg {n} font file(s) are now in {folder} {n} berkas font kini berada di {folder} +@msg {n} font file(s) are now in {folder}; {failed} couldn't be added: {reason} {n} berkas font kini berada di {folder}; {failed} tidak dapat ditambahkan: {reason} +@msg {n} font(s) aren't available here: their text shows in another font {n} font tidak tersedia di sini: teksnya ditampilkan dalam font lain +@msg not a file bukan berkas +@msg not a font file bukan berkas font +@msg that document is no longer open dokumen tersebut tidak lagi terbuka +@msg larger than 256 MB lebih besar dari 256 MB +@msg the files come to more than 1 GB: add fewer at a time total berkas melebihi 1 GB: tambahkan lebih sedikit sekaligus +@msg every numbered name for it is taken in the Fonts folder semua nama bernomor untuk font tersebut sudah dipakai di folder Fonts +@msg every numbered name for {name} is taken in the document's folder semua nama bernomor untuk {name} sudah dipakai di folder dokumen +@msg No linked files to look for Tidak ada berkas tertaut untuk dicari +@msg Relinked {n} image(s); {name} couldn't be relinked: {reason} {n} gambar ditautkan ulang; {name} tidak dapat ditautkan ulang: {reason} +@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` adalah referensi langkah {r}, yang hanya diselesaikan oleh `run` dan `command_batch` +@msg `ids` must be an array of object ids, not {v} `ids` harus berupa array id objek, bukan {v} +@msg `corners` must be an array of anchor indices, not {v} `corners` harus berupa array indeks jangkar, bukan {v} +@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: hasil langkah {i} tidak memiliki nilai seperti itu (tulis $$ untuk $ literal) +@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: tidak ada langkah {i} sebelum langkah ini (langkah dihitung mulai dari 0; tulis $$ untuk $ literal) +@msg object ids are non-negative integers, not {v} id objek adalah bilangan bulat non-negatif, bukan {v} +@msg automation filesystem access is not granted: read authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas baca tidak ada: {path} +@msg automation filesystem access is not granted: write authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas tulis tidak ada: {path} +@msg automation path rejected: outside the read root {root}: {path} jalur otomatisasi ditolak: di luar akar baca {root}: {path} +@msg automation path rejected: outside the write root {root}: {path} jalur otomatisasi ditolak: di luar akar tulis {root}: {path} +@msg automation path rejected: path is empty jalur otomatisasi ditolak: jalur kosong +@msg automation path rejected: a link that leads nowhere: {path} jalur otomatisasi ditolak: tautan yang tidak mengarah ke mana pun: {path} +@msg automation path rejected: cannot resolve {path}: {e} jalur otomatisasi ditolak: tidak dapat menyelesaikan {path}: {e} +@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} jalur otomatisasi ditolak: penelusuran induk di bawah folder yang tidak ada: {path} +@msg automation path rejected: reserved device names are not allowed: {path} jalur otomatisasi ditolak: nama perangkat tercadang tidak diizinkan: {path} +@msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode berkas PSD Multichannel tidak dapat dibaca: simpan dalam mode RGB, CMYK, atau Grayscale +@msg not a PSD file bukan berkas PSD +@msg the PSD file has an unknown colour mode ({m}) berkas PSD memiliki mode warna yang tidak dikenal ({m}) +@msg the PSD file's image data uses an unknown compression ({m}) data gambar berkas PSD menggunakan kompresi yang tidak dikenal ({m}) +@msg the PSD image is too large to read ({_1} × {_2} pixels) gambar PSD terlalu besar untuk dibaca ({_1} × {_2} piksel) +@msg zipped 32-bit PSD image data with prediction can't be read data gambar PSD 32-bit terkompresi zip dengan prediksi tidak dapat dibaca +@msg {depth}-bit PSD files in this colour mode can't be read berkas PSD {depth}-bit dalam mode warna ini tidak dapat dibaca +@msg the PSD file is cut short berkas PSD terpotong +@msg the PSD file's header is damaged header berkas PSD rusak +@msg the PSD file's image data can't be read: {e} data gambar berkas PSD tidak dapat dibaca: {e} +@msg Imported library {name} Pustaka {name} diimpor +@msg Invalid panel layout; restored the default dock Tata letak panel tidak valid; dock default dipulihkan +@msg action is required action diperlukan +@msg anchor must name a registered panel anchor harus menyebutkan panel terdaftar +@msg before must name a panel before harus menyebutkan panel +@msg docking action contains an unknown panel or an excessive split path tindakan docking berisi panel tidak dikenal atau jalur pemisahan berlebihan +@msg group must be true or false group harus true atau false +@msg move requires a destination panel move memerlukan panel tujuan +@msg move requires an existing destination panel move memerlukan panel tujuan yang sudah ada +@msg onto must name a floating panel onto harus menyebutkan panel mengambang +@msg panel group is empty grup panel kosong +@msg panel must name a registered panel panel harus menyebutkan panel terdaftar +@msg saved panel layout is invalid; reset the workspace first tata letak panel tersimpan tidak valid; atur ulang ruang kerja dulu +@msg zone must be a string zone harus berupa string +@msg zone must be tab, center, left, right, top or bottom zone harus berupa tab, center, left, right, top, atau bottom +@msg {key} must be a finite coordinate {key} harus berupa koordinat berhingga +@msg x and y must be provided together x dan y harus diberikan bersamaan +@msg invalid restored panel group grup panel yang dipulihkan tidak valid +@msg invalid native panel set kumpulan panel native tidak valid +@msg missing panel layout tata letak panel tidak ada +@msg unsupported native docking operation operasi docking native tidak didukung +@msg this kind of brush has no options dialog yet jenis kuas ini belum memiliki dialog opsi + Ruler Origin Titik asal penggaris + Reset Ruler Origin Atur ulang titik asal penggaris + Column Width: Lebar kolom: + Import Data… Impor data… + Number of Decimals: Jumlah desimal: + Switch x/y Tukar x/y + Transpose Transpos + Phone 393×852 Ponsel 393×852 + Phone 430×932 Ponsel 430×932 + Phone 360×800 Ponsel 360×800 + Phone 412×915 Ponsel 412×915 + Phone 375×667 Ponsel 375×667 + Tablet 768×1024 Tablet 768×1024 + Tablet 820×1180 Tablet 820×1180 + Tablet 834×1194 Tablet 834×1194 + Tablet 1024×1366 Tablet 1024×1366 + Tablet 800×1280 Tablet 800×1280 + Watch 184×224 Jam tangan 184×224 + Watch 198×242 Jam tangan 198×242 + Web 1600×900 Web 1600×900 + Web 1440×900 Web 1440×900 + Web 1366×768 Web 1366×768 + Web 1280×800 Web 1280×800 + Web 1024×768 Web 1024×768 + Legal Legal + Tabloid Tabloid + A3 A3 + A4 A4 + A5 A5 + B4 B4 + B5 B5 + HDTV 1080p HDTV 1080p + HDTV 720p HDTV 720p + 4K UHD 4K UHD + 8K UHD 8K UHD + 2K Cinema 2K Cinema + 4K Cinema 4K Cinema + Greeting Card Kartu ucapan + Square 8 × 8 in Persegi 8 × 8 in + Poster 18 × 24 in Poster 18 × 24 in + Poster 24 × 36 in Poster 24 × 36 in + Logo 1000×1000 Logo 1000×1000 + Business Card 3.5 × 2 in Kartu nama 3.5 × 2 in + Business Card 85 × 55 mm Kartu nama 85 × 55 mm + Letterhead Letter Kop surat Letter + Letterhead A4 Kop surat A4 + Envelope DL Amplop DL + Envelope #10 Amplop #10 + Social Portrait Post 1080×1350 Postingan media sosial potret 1080×1350 + Social Landscape Post 1200×630 Postingan media sosial lanskap 1200×630 + Social Story 1080×1920 Story media sosial 1080×1920 + Social Tall Post 1000×1500 Postingan media sosial tinggi 1000×1500 + Profile Cover 1500×500 Sampul profil 1500×500 + Page Cover 1640×624 Sampul halaman 1640×624 + Video Banner 2560×1440 Banner video 2560×1440 + Video Thumbnail 1280×720 Thumbnail video 1280×720 + Keyboard Shortcuts Pintasan papan ketik + Set: Set: + Reset to Defaults Atur ulang ke default + Tool Alat + Command Perintah + Shortcut Pintasan + Menu Commands Perintah menu + Press keys… Tekan tombol… + Use Default Gunakan default + Go to Conflict Buka konflik + Remove the shortcut Hapus pintasan + Click, then press the new shortcut (Esc cancels) Klik, lalu tekan pintasan baru (Esc membatalkan) + All shortcuts reset to defaults. Semua pintasan diatur ulang ke default. + Imported. Press OK to keep the imported set. Terimpor. Tekan OK untuk mempertahankan set yang diimpor. + ⚠ {chord} was already used by {names} — it has been removed there. Peringatan: {chord} sudah digunakan oleh {names} — pintasan tersebut telah dihapus di sana. + VectorCraft Defaults Default VectorCraft +@plural {n} shortcut is assigned more than once|{n} shortcuts are assigned more than once {n} pintasan ditetapkan lebih dari satu kali + Simulate prints overprints as Overprint Preview shows them; separations always keep them. A preset flattens transparency before printing (PostScript files always flatten it). Simulasi mencetak overprint sebagaimana ditampilkan Pratinjau Overprint; separasi selalu mempertahankannya. Preset meratakan transparansi sebelum mencetak (berkas PostScript selalu meratakannya). + Preserve Pertahankan + Simulate Simulasikan + Portrait (flipped) Potret (dibalik) + Landscape (flipped) Lanskap (dibalik) + Visible & Printable Layers Lapisan terlihat & dapat dicetak + Visible Layers Lapisan terlihat + All Layers Semua lapisan + Do Not Scale Jangan skalakan + Fit to Page Pas ke halaman + Custom Scale Skala kustom + Tile Imageable Areas Ubin area yang dapat dicetak + Composite Komposit + Separations Separasi + Up (right reading) Atas (terbaca benar) + Down (right reading) Bawah (terbaca benar) + Positive Positif + Negative Negatif + Subset Subset + Complete Lengkap + [Custom] [Kustom] + [Default] [Default] + No downsampling Tanpa downsampling + Subsample Subsample + Bicubic Bikubik + Minimum Minimum + Run Length Run Length + Roman Roman + No conversion Tanpa konversi + Convert to destination Konversi ke tujuan + Convert to destination (preserve numbers) Konversi ke tujuan (pertahankan angka) + Don't include profiles Jangan sertakan profil + Include all profiles Sertakan semua profil + Include destination profile Sertakan profil tujuan + Include tagged source profiles Sertakan profil sumber yang ditandai + Low resolution (150 ppi) Resolusi rendah (150 ppi) + High resolution Resolusi tinggi + Inserting, deleting and rotating pages Menyisipkan, menghapus, dan memutar halaman + Filling in form fields and signing Mengisi kolom formulir dan menandatangani + Commenting, filling in form fields and signing Mengomentari, mengisi kolom formulir, dan menandatangani + Any except extracting pages Semua kecuali mengekstrak halaman + Bounding Box Kotak pembatas + Mobile Seluler + Film & Video Film & video + Art & Illustration Seni & ilustrasi + Branding Branding + Social Media sosial + Triangle Segitiga + Triangle (open) Segitiga (terbuka) + Circle (open) Lingkaran (terbuka) + Square (open) Persegi (terbuka) + Bar Batang + Arrow Panah + Arrow (open) Panah (terbuka) + Barbed Berduri + Half Arrow (left) Setengah panah (kiri) + Half Arrow (right) Setengah panah (kanan) + Concave Cekung + Double Bar Batang ganda + Feather Bulu + Dot on Bar Titik pada batang + Chevron Chevron + Double Arrow Panah ganda + Cross Silang + Plus Plus + Hexagon Heksagon + Hexagon (open) Heksagon (terbuka) + Tag Label + Tag (open) Label (terbuka) + Half Circle Setengah lingkaran + Drop Tetes + Slash Garis miring + Double Slash Garis miring ganda + Diamond (open) Wajik (terbuka) + Reverse Triangle Segitiga terbalik + Swallowtail Ekor walet + Bracket Braket + Fork Garpu + Leaf Daun + Kite Layang-layang + Triangle to Bar Segitiga ke batang + Oval Oval + Complementary Komplementer + Complementary 2 Komplementer 2 + Split Complementary Komplementer terpisah + Left Complement Komplemen kiri + Right Complement Komplemen kanan + Analogous Analog + Analogous 2 Analog 2 + Monochromatic Monokromatik + Shades Gradasi + Triad Triad + Triad 2 Triad 2 + Triad 3 Triad 3 + Tetrad Tetrad + Tetrad 2 Tetrad 2 + Tetrad 3 Tetrad 3 + Compound Campuran + Compound 2 Campuran 2 + High Contrast Kontras tinggi + High Contrast 2 Kontras tinggi 2 + High Contrast 3 Kontras tinggi 3 + Pentagram Pentagram + Cool Sejuk + Warm Hangat + Muted Redup + Vivid Cerah + Spectrum Spektrum + Perceptual Perseptual + Relative Colorimetric Kolorimetrik relatif + Absolute Colorimetric Kolorimetrik absolut + Exact Eksak + Preserve Tints Pertahankan tint + Scale Tints Skalakan tint + Linear Linear + Freeform Bentuk bebas + 8 Colors 8 warna + 16 Colors 16 warna + 256 Colors 256 warna + True Colors Warna sejati + Preserve Appearance Pertahankan penampilan + Maximize Editability Maksimalkan ketereditan + Clip Group Grup klip + Guide Panduan + Symbol Simbol + Envelope Amplop + Mesh Mesh + Placed Document Dokumen yang ditempatkan + Radial Repeat Pengulangan radial + Grid Repeat Pengulangan kisi + Mirror Repeat Pengulangan cermin + Auto Size Ukuran otomatis + Shrink Text to Fit Perkecil teks agar pas + Ascent Ascent + Cap Height Tinggi kapital + x Height Tinggi x + Justify Ratakan + First Baseline Baseline pertama + Vertical Align Perataan vertikal + Save Selection Simpan seleksi + No Legend Tanpa legenda + Standard Legend Legenda standar + Legends in Wedges Legenda di juring + Ratio Rasio + Even Merata + Stacked Tertumpuk + Twirl Pilin + Pucker Pucker + Bloat Bloat + Scallop Scallop + Wrinkle Kerut + Unknown Tidak diketahui + Copyrighted Berhak cipta + Public Domain Domain publik + None (Color Preview) Tanpa (pratinjau warna) + Rasterized Complex Regions Area kompleks yang dirasterisasi + Transparent Objects Objek transparan + All Affected Objects Semua objek yang terpengaruh + Expanded Patterns Pola yang diperluas + Outlined Strokes Goresan yang dijadikan garis luar + Outlined Text Teks yang dijadikan garis luar + All Rasterized Regions Semua area yang dirasterisasi + High Resolution Resolusi tinggi + [High Resolution] [Resolusi tinggi] + Medium Resolution Resolusi sedang + [Medium Resolution] [Resolusi sedang] + Low Resolution Resolusi rendah + [Low Resolution] [Resolusi rendah] + Copyright and Contact Info Info hak cipta dan kontak + All Slices Semua slice + Selected Slices Slice terpilih + No Slices Tanpa slice + Images Only Hanya gambar + HTML and Images HTML dan gambar + Selective Selektif + Adaptive Adaptif + Black & White Hitam & putih + No Dither Tanpa dither + Diffusion Difusi + Unsorted Tidak diurutkan + Sort by Hue Urutkan berdasarkan rona + Sort by Luminance Urutkan berdasarkan luminansi + Sort by Popularity Urutkan berdasarkan popularitas + Baseline (Standard) Baseline (standar) + Baseline Optimized Baseline teroptimalkan + Art Optimized Dioptimalkan untuk seni + Type Optimized Dioptimalkan untuk teks + Language Level 2 Language Level 2 + Language Level 3 Language Level 3 + TIFF (Black & White) TIFF (hitam & putih) + TIFF (8-bit Color) TIFF (warna 8-bit) + 1 bit 1 bit + 4 bit 4 bit + 8 bit 8 bit + 16 bit 16 bit + 24 bit 24 bit + 32 bit 32 bit + 16 bits/pixel 16 bit/piksel + 24 bits/pixel 24 bit/piksel + 32 bits/pixel 32 bit/piksel + DWG (use DXF) DWG (gunakan DXF) + Artistic Artistik + Film Grain… Butiran film… + Highlight Area Area sorotan +file-filter All readable files Semua berkas yang dapat dibaca +file-filter All placeable files Semua berkas yang dapat ditempatkan +file-filter Swatch libraries Pustaka swatch +file-filter Flattener presets Preset perata +file-filter PDF presets Preset PDF +file-filter Print presets Preset cetak +file-filter SVG Compressed SVG terkompresi +file-filter PDF-compatible .ai .ai kompatibel PDF +file-filter VectorCraft Template Templat VectorCraft +file-filter Templates Templat +diagnostics render Render +diagnostics ui UI + Preset {number} Preset {number} + PRs PR + @{login}: {prs}, {commits}, +{added} / −{deleted} lines (Δ {delta}), +{binary_added} / −{binary_deleted} binary assets, {first} – {last} @{login}: {prs}, {commits}, +{added} / −{deleted} baris (Δ {delta}), +{binary_added} / −{binary_deleted} aset biner, {first} – {last} +@plural {n} PR|{n} PRs {n} PR +@plural {n} commit|{n} commits {n} commit +gradient-kind Radial Radial +mark-style Japanese Jepang +@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) berkas terkompresi ini hasil ekstraksinya melebihi yang dibaca VectorCraft ({_1} MB dokumen, {_2} MB per gambar) +@msg the document is too large to save compressed ({0}): save it with Use Compression off dokumen terlalu besar untuk disimpan terkompresi ({0}): simpan dengan Gunakan Kompresi nonaktif +@msg the file stopped being read (please report this bug) pembacaan berkas terhenti (mohon laporkan bug ini) + Concentric Dividers: Pembagi konsentris: + Radial Dividers: Pembagi radial: + Skew: Kemiringan: + Inside Di dalam + Outside Di luar + Use Outside Rectangle As Frame Gunakan persegi panjang luar sebagai bingkai + Create Compound Path From Ellipses Buat jalur majemuk dari elips + Fill Grid Isi kisi + Select two or more objects, or a group Pilih dua objek atau lebih, atau sebuah grup + Back Belakang + Both sides Kedua sisi + Classic Klasik + Cove Cove + Depth Kedalaman + Front Depan + Isometric Bottom Isometrik bawah + Isometric Left Isometrik kiri + Isometric Right Isometrik kanan + Isometric Top Isometrik atas + Off-Axis Back Di luar sumbu belakang + Off-Axis Bottom Di luar sumbu bawah + Off-Axis Front Di luar sumbu depan + Off-Axis Left Di luar sumbu kiri + Off-Axis Right Di luar sumbu kanan + Off-Axis Top Di luar sumbu atas + Ogee Ogee + Reset all 3D settings to their defaults. Atur ulang semua pengaturan 3D ke defaultnya. + Step Langkah + Taper Tirus + Twist Puntir + Volume Volume +3d Cap Ujung +3d Solid Padat +3d Hollow Berongga +3d Bevel Bevel +3d Rounded Bulat +3d Space Spasi + Bevel Shape Bentuk bevel + Bevel Inside Bevel di dalam + Bevel both sides Bevel di kedua sisi + Presets Preset +@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Sebuah objek hanya menerima satu efek 3D. Edit opsi efek yang sudah ada, atau hapus dulu efek tersebut. +@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} saat ini memerlukan jalur atau jalur majemuk. Konversikan dulu teks, gambar, dan grup menjadi jalur. +@msg {label} requires a shape with at least two anchors {label} memerlukan bentuk dengan setidaknya dua jangkar +@msg The shape is too complex for a 3D effect Bentuknya terlalu kompleks untuk efek 3D +@msg The 3D surface is too complex; simplify the shape Permukaan 3D terlalu kompleks; sederhanakan bentuknya + Transform Objects Transformasikan objek +@msg unknown parameter `{key}`; did you mean `{near}`? parameter `{key}` tidak dikenal; apakah maksud Anda `{near}`? +@msg unknown parameter `{key}`; the command takes no parameters parameter `{key}` tidak dikenal; perintah tidak menerima parameter +@msg unknown parameter `{key}`; the command takes: {keys} parameter `{key}` tidak dikenal; perintah menerima: {keys} + Load EPS into Clipboard Muat EPS ke papan klip +@msg an image of {w} × {h} pixels is too large to draw gambar {w} × {h} piksel terlalu besar untuk digambar + Fill and Stroke Controls Kontrol isian dan goresan + Coloring Controls Kontrol pewarnaan + Draw Mode Controls Kontrol mode menggambar + Screen Mode Controls Kontrol mode layar + Drawer menu Menu laci + Toolbar: Show Controls Bilah alat: Tampilkan kontrol +@msg control must be fillStroke, coloring, drawMode or screenMode control harus fillStroke, coloring, drawMode, atau screenMode +@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode control `{name}` tidak dikenal: fillStroke, coloring, drawMode, atau screenMode +@msg give a fraction `t` from 0 to 1 berikan pecahan `t` dari 0 sampai 1 +@msg give the path `id` berikan `id` jalur +@msg invalid stroke widths lebar goresan tidak valid +@msg object has no weighted stroke objek tidak memiliki goresan berbobot + Single Corner Radius Radius sudut tunggal + Top Left: Kiri atas: + Top Right: Kanan atas: + Bottom Left: Kiri bawah: + Bottom Right: Kanan bawah: + Softness Kelembutan + Ambient Light Cahaya ambient + Light behind the object Cahaya di belakang objek + 2up 2up + 4up 4up + A6 A6 + Convert to Global Konversi ke global + Grey Abu-abu + Text Displayed in Cell Teks yang ditampilkan dalam sel + Working RGB RGB kerja + Art and Bright Colors Seni dan warna cerah + Dot Gain 15% Dot Gain 15% + Expand 1 Selected Perluas 1 yang dipilih + Expand Object Perluas objek + Gray Gamma 2.2 Gray Gamma 2.2 + Phone 375×812 Ponsel 375×812 + Photo Low Fidelity Foto ketelitian rendah + Print & Proof Cetak & uji + Printing & Proofing Pencetakan & pengujian + Restricted Colors Warna terbatas + Simplified Chinese Tionghoa sederhana + Tablet 1024×768 Tablet 1024×768 + Video 1920×1080 Video 1920×1080 + Video 3840×2160 Video 3840×2160 + Web 1280×720 Web 1280×720 +@msg give corners of the path: anchors between two straight sides berikan sudut jalur: jangkar di antara dua sisi lurus + [1P-High View] [Tampilan 1P-Tinggi] + [1P-Low View] [Tampilan 1P-Rendah] + [1P-Normal View] [Tampilan 1P-Normal] + [2P-High View] [Tampilan 2P-Tinggi] + [2P-Low View] [Tampilan 2P-Rendah] + [2P-Normal View] [Tampilan 2P-Normal] + [3P-Low View] [Tampilan 3P-Rendah] + [3P-Normal View] [Tampilan 3P-Normal] + Automation Automation + Essentials Essentials + Essentials Classic Essentials Classic + Layout Layout + OpenType OpenType + Painting Painting + Printing and Proofing Printing and Proofing + Tracing Tracing + Typography Typography + VectorCraft VectorCraft + Web Web + Decrease Font Size by Five Steps Perkecil ukuran font lima langkah + Increase Font Size by Five Steps Perbesar ukuran font lima langkah + Intertwine Jalin + Arrange Intertwine Area Atur area jalin + Rotate View to Selection Putar tampilan ke seleksi +@msg select an Intertwine group pilih grup jalin +@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS ini adalah file PostScript (file .ai yang disimpan dalam format lama atau tanpa kompatibilitas PDF, atau file EPS), bukan PDF: File › Open dan File › Place membacanya sebagai EPS +@msg The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set Peluncuran terakhir VectorCraft berakhir pada {name} sebelum jendelanya muncul, sehingga dimulai pada {summary}. Untuk mencoba prosesor grafis itu lagi, ubah Preferences › Performance › Graphics Processor atau mulai dengan WGPU_BACKEND diatur +@msg The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set Peluncuran terakhir VectorCraft berakhir saat mencantumkan prosesor grafis, sehingga dimulai pada {summary} dengan OpenGL saja. Untuk mencoba backend grafis lainnya lagi, ubah Preferences › Performance › Graphics Processor atau mulai dengan WGPU_BACKEND diatur + Loaded Texture Tekstur yang dimuat + Load Texture… Muat tekstur… + Texture Image Gambar tekstur +@msg the image can't be read as a texture gambar tidak dapat dibaca sebagai tekstur + Legacy (version 7) Warisan (versi 7) + Transparent Images: Gambar transparan: + Flatten Ratakan + Omit Abaikan + Legacy .ai Options Opsi .ai warisan + A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. File .ai warisan memuat satu artboard: setiap artboard yang Anda centang disimpan sebagai file terpisah. + {count} of {total} artboards {count} dari {total} artboard + Check an artboard to save. Centang artboard yang akan disimpan. + Hidden Objects: Objek tersembunyi: + Hidden Layer Lapisan tersembunyi + Make Visible Tampilkan + Text: Teks: + Editable Dapat diedit + PDF-compatible Kompatibel dengan PDF + Dimension Dimensi + Drawing : real size — 1:10 labels 1 unit on the page as 10. Gambar : ukuran sebenarnya — 1:10 menandai 1 unit di halaman sebagai 10. + Preview (1 inch on the page) Pratinjau (1 inci di halaman) + Set as Default Atur sebagai default + out from a highlighted edge to place its dimension | keluar dari tepi yang disorot untuk menempatkan dimensinya | + cancels | Double-click the tool for options membatalkan | Klik dua kali alat untuk opsi + Angular Sudut + Away from the Line Menjauhi garis + Between Line and Object Antara garis dan objek + Drag out from a segment, or click two points, then click to place. Seret keluar dari segmen, atau klik dua titik, lalu klik untuk menempatkan. + Drag out from a corner, or click two straight lines, then click to place. Seret keluar dari sudut, atau klik dua garis lurus, lalu klik untuk menempatkan. + Drag out from a circle or arc for its radius, inwards for its diameter. Seret keluar dari lingkaran atau busur untuk radiusnya, ke dalam untuk diameternya. +@msg Choose a circle or a perfect arc Pilih lingkaran atau busur yang sempurna +@msg Choose a precision from 0 to 6 Pilih presisi dari 0 sampai 6 + Create Dimension Buat dimensi + Dimension Tool Alat dimensi + Dimension Tool Options Opsi alat dimensi + Display Tampilan + Font Family: Keluarga font: + Font Style: Gaya font: + Precision: Presisi: + Update Dimensions Perbarui dimensi +@msg Angles require two straight segments Sudut memerlukan dua segmen lurus +@msg Choose two different points Pilih dua titik yang berbeda +@msg Choose two non-parallel straight segments Pilih dua segmen lurus yang tidak sejajar +@msg Choose two nonzero angle arms Pilih dua lengan sudut yang tidak nol +@msg Dimension point is outside the canvas Titik dimensi berada di luar kanvas +@msg Dimension size is outside its supported range Ukuran dimensi berada di luar rentang yang didukung +@msg First measured segment no longer exists Segmen yang diukur pertama sudah tidak ada +@msg Invalid dimension distance Jarak dimensi tidak valid +@msg Invalid dimension font name Nama font dimensi tidak valid +@msg Invalid drawing scale Skala gambar tidak valid +@msg Invalid real scale Skala sebenarnya tidak valid +@msg Measured anchor no longer exists Titik jangkar yang diukur sudah tidak ada +@msg Measured segment no longer exists Segmen yang diukur sudah tidak ada +@msg Non-finite dimension Dimensi bukan bilangan berhingga +@msg Scale must be drawing:real, for example 1:10 Skala harus dalam format gambar:sebenarnya, misalnya 1:10 +@msg Scale ratio must be between 1:1000000 and 1000000:1 Rasio skala harus antara 1:1000000 dan 1000000:1 +@msg Second measured segment no longer exists Segmen yang diukur kedua sudah tidak ada +@msg Unknown dimension mode Mode dimensi tidak dikenal +@msg Unknown dimension option {k} Opsi dimensi {k} tidak dikenal +@msg Unknown dimension style Gaya dimensi tidak dikenal +@msg Unknown unit Satuan tidak dikenal + Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Menutup ujung melingkar profil terbuka. Sapuan parsial mempertahankan sisi potongannya tetap terbuka. + Darkness Kegelapan + Isolate Selected Object Isolasikan objek terpilih + Keep selected Tetap terseleksi + Sample Color from Screen Ambil warna dari layar + The active document changed while the screen color sampler was open Dokumen aktif berubah saat pengambil warna layar terbuka + This system has no screen color sampler Sistem ini tidak memiliki pengambil warna layar + Color blindness – Tritanopia-type Buta warna – tipe Tritanopia + Panels Except Tools and Control Semua panel kecuali Alat dan Kontrol + Reset Tracking and Kerning Atur ulang pelacakan dan kerning + Reset Horizontal Scale Atur ulang skala horizontal +@msg span must be "object" or "selection", got {v} span harus "object" atau "selection", diterima {v} +@msg span: "selection" places a `gradient`, not a swatch span: "selection" menempatkan `gradient`, bukan swatch +@msg span: "selection" places a gradient: give `gradient` span: "selection" menempatkan gradien: berikan `gradient` +@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" menempatkan gradien linear atau radial, bukan gradien bebas +@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" menempatkan gradien itu sendiri: jangan sertakan `start` dan `end` (keduanya sudah menempatkannya dalam koordinat dokumen) + Make Image Dynamic Jadikan gambar dinamis + Import Data Sets… Impor himpunan data… + Export Data Sets… Ekspor himpunan data… + Make Graph Dynamic Jadikan grafik dinamis + Batch… Batch… + Source Folder: Folder sumber: + Destination Folder: Folder tujuan: + Overrides Penimpaan + Uses every data set of the open document. Menggunakan setiap himpunan data dari dokumen yang terbuka. +@msg Batch: {count} processed Batch: {count} diproses +@msg Batch: {count} processed, {failed} failed Batch: {count} diproses, {failed} gagal +@msg choose a destination folder pilih folder tujuan +@msg choose a source folder pilih folder sumber +@msg open a document with data sets buka dokumen dengan himpunan data +@msg the action has no steps action tidak memiliki langkah + Registration: Registrasi: + By Layer Per lapisan + By Object Per objek + Clear All Hapus semua + Filter Filter + Groups Grup + Hidden Tersembunyi + Images Gambar + Linked Files File tertaut + Locked Terkunci + Color Panel Options Opsi panel warna + Filter Layers Panel Rows Filter baris panel lapisan + Hide Template Sembunyikan templat + Show Template Tampilkan templat + Accent Color Warna aksen + Amber Amber + Coral Koral + Violet Violet + Color Scheme Skema warna + Modern Modern +@msg `{key}` must be an array of these names: {_1} `{key}` harus berupa array dari nama-nama ini: {_1} + Mode Mode + Blur Mode Mode blur + Edge Only Hanya tepi + Overlay Edge Hamparkan tepi + Path Length Panjang jalur +@msg the file's layers weren't read ({_1}): it comes in as its page, in one layer lapisan file tidak terbaca ({_1}): file masuk sebagai halamannya, dalam satu lapisan +@msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part lapisan file tidak terbaca dari data pengeditannya ({_1}): lapisannya berasal dari bagian PDF file +@msg `mode` must be grayscale, rgb, hsb, cmyk or websafe `mode` harus grayscale, rgb, hsb, cmyk atau websafe +@msg the Color Picker is not open Pemilih Warna tidak terbuka +@msg the screen color sampler is already open pengambil warna layar sudah terbuka +@msg this system has no screen color sampler sistem ini tidak memiliki pengambil warna layar +swatch [None] [Tidak ada] +swatch [Registration] [Registrasi] +swatch White Putih +swatch Black Hitam +swatch Red Merah +swatch Orange Red Merah oranye +swatch Orange Oranye +swatch Amber Amber +swatch Yellow Kuning +swatch Yellow Green Kuning hijau +swatch Lime Lime +swatch Green Hijau +swatch Emerald Zamrud +swatch Teal Teal +swatch Aqua Aqua +swatch Cyan Green Hijau sian +swatch Cyan Sian +swatch Azure Azure +swatch Blue Biru +swatch Indigo Indigo +swatch Violet Violet +swatch Purple Ungu +swatch Magenta Magenta +swatch Rose Mawar +swatch Pink Merah muda +swatch Brown Cokelat +swatch Tan Cokelat kekuningan +swatch Coffee Kopi +swatch Chocolate Cokelat pekat +swatch Sand Pasir +swatch Copper Tembaga +swatch Rust Karat +swatch Walnut Walnut +swatch Espresso Espresso +swatch White, Black Putih, Hitam +swatch Radial White, Black Radial putih, hitam +swatch Sunset Senja +swatch Ocean Samudra +swatch Grays Abu-abu +swatch Brights Warna cerah +swatch Bright Red Merah cerah +swatch Bright Yellow Kuning cerah +swatch Bright Green Hijau cerah +swatch Bright Blue Biru cerah +swatch Bright Violet Violet cerah + Automate Otomatiskan + Include All Subdirectories Sertakan semua subdirektori + Override Action "Open" Commands Timpa perintah "Open" pada action + Override Action "Save" Commands Timpa perintah "Save" pada action + Override Action "Export" Commands Timpa perintah "Export" pada action + Stop on Errors Berhenti saat terjadi kesalahan + Save and Close Simpan dan tutup + Play: Jalankan: + +# Strings main added after this catalog was written + Convert to Straight Lines Konversi ke garis lurus + Corner Point Angle Threshold: Ambang sudut titik sudut: + A File per Object Satu file per objek + Example: {name} Contoh: {name} + Export Preset: Preset ekspor: + Folders: Folder: + Insert Sisipkan + Nothing is selected. Select the art to export, then open this tab again. Tidak ada yang dipilih. Pilih karya yang akan diekspor, lalu buka tab ini lagi. + One File of the Selection Satu file dari seleksi + Start at: Mulai dari: + {n} objects selected {n} objek dipilih + Delete Export Preset Hapus preset ekspor + Export Presets Daftar preset ekspor + Save Export Preset Simpan preset ekspor + Export Layer Ekspor lapisan + Export Selection Ekspor seleksi + All in One Folder Semua dalam satu folder + By Size or Row Menurut ukuran atau baris + By Format Menurut format + By Artboard or Item Menurut artboard atau item + By Item, Then Format Menurut item, lalu format + Document Name Nama dokumen + Artboard or Item Name Nama artboard atau item + Number (####) Nomor (####) + Date (YYYYMMDD) Tanggal (YYYYMMDD) + Date (YYYY-MM-DD) Tanggal (YYYY-MM-DD) + Time (hhmmss) Waktu (hhmmss) + PDF Print PDF cetak + Words Longer Than: Kata lebih panjang dari: + After First: Setelah awal: + Before Last: Sebelum akhir: + Hyphen Limit: Batas tanda hubung: + Hyphenation Zone: Zona pemenggalan: + letters huruf + hyphens tanda hubung + Hyphenate Capitalized Words Penggal kata berhuruf kapital + Outline Object Kerangka objek + Update Selection Perbarui seleksi + Show Original Path Tampilkan jalur asli +@msg check a layer to export centang lapisan yang akan diekspor +@msg select the art to export first pilih dulu karya yang akan diekspor +@msg no saved selection recalled tidak ada seleksi tersimpan yang dipanggil + Merge only with selection Gabungkan hanya dengan seleksi + Sketch Sketsa + Halftone Pattern… Pola halftone… + Halftone Pattern Pola halftone + Pattern Type Jenis pola + Dot Titik + Export Type: Jenis ekspor: + Movie Clip Klip film +@msg name is empty nama kosong +@msg no symbol named `{n}` tidak ada simbol bernama `{n}` diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 6b06d87ad..e2d8139d6 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -94,7 +94,7 @@ fn plural_polish(n: u64) -> usize { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 20] = [ +pub static LANGUAGES: [LangInfo; 23] = [ LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, // Greek: the whole interface and the status and error messages, keeping the same names in // English as Spanish; every `el-*` locale (including `el-GR` and `el-CY`) resolves here. @@ -232,6 +232,26 @@ pub static LANGUAGES: [LangInfo; 20] = [ plural: plural_none, complete_menus: true, catalog: OnceLock::new(), + }, // Dutch; every `nl-*` locale (`nl-NL`, `nl-BE`, `nl_NL.UTF-8`…) resolves here. + LangInfo { + code: "nl", + name: "Nederlands", + source: include_str!("nl.tsv"), + plural: plural_one_other, + complete_menus: true, + catalog: OnceLock::new(), + }, + // Danish; every `da-*` locale (`da-DK`, `da_DK.UTF-8`…) resolves here. + LangInfo { code: "da", name: "Dansk", source: include_str!("da.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + // Indonesian; every `id-*` locale (`id-ID`, `id_ID.UTF-8`…) resolves here. CLDR `id` counts + // have one form, so `@plural` entries list a single form. + LangInfo { + code: "id", + name: "Bahasa Indonesia", + source: include_str!("id.tsv"), + plural: plural_none, + complete_menus: true, + catalog: OnceLock::new(), }, // Finnish: the whole interface and the status and error messages, keeping the same names in // English as Spanish; one/other plurals with the partitive after any number but 1 ("2 tasoa"). diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv new file mode 100644 index 000000000..0d31d8e61 --- /dev/null +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -0,0 +1,5035 @@ +# Dutch (nl) catalog. Same format as zh-hant.tsv: context source translation +# (empty context = a plain UI string; `@id` = a command id; `@plural` = "one|other" forms) +# +# Clean-room: written from the meaning of VectorCraft's English labels in neutral, international +# Dutch, using the feature names Dutch speakers of vector illustration software know. No other +# localisation was copied. Labels use sentence case (only the first word capitalised). + New Design Nieuw ontwerp + Delete Design Ontwerp verwijderen + Paste Design Ontwerp plakken + Select Unused Ongebruikte selecteren + Glowing Edges… Gloeiende randen… + About VectorCraft Over VectorCraft + Align to Path Uitlijnen op pad + Cut Path at Selected Anchor Points Pad knippen op geselecteerde ankerpunten + Join Our Discord Word lid van onze Discord + Language Taal + UI Brightness UI-helderheid + Dark Donker + Medium Dark Gemiddeld donker + Medium Light Gemiddeld licht + Light Licht + Quit VectorCraft VectorCraft afsluiten + File Bestand + New… Nieuw… + New from Template… Nieuw van sjabloon… + Open… Openen… + Open Recent Files Recente bestanden openen + Clear Recent Files Recente bestanden wissen + Show in Folder Tonen in map + Close Sluiten +@id file.close file.close + Close All Alles sluiten + Save Opslaan + Save As… Opslaan als… + Save a Copy… Een kopie opslaan… + Save as Template… Opslaan als sjabloon… + Save as PDF… Opslaan als PDF… + Save for Office Documents… Opslaan voor Office-documenten… + Revert Terugzetten + Place… Plaatsen… + Export Exporteren + Export for Screens… Exporteren voor schermen… + Export As… Exporteren als… + Export As SVG… Exporteren als SVG… + Export As PNG… Exporteren als PNG… + Save for Web (Legacy)… Opslaan voor web (klassiek)… + Export Selection… Selectie exporteren… + Package… Verpakken… + Scripts Scripts + Check Script… Script controleren… + Check Script Script controleren + Other Script… Ander script… + Document Setup… Documentinstellingen… + Document Color Mode Documentkleurmodus + CMYK Color CMYK-kleur + RGB Color RGB-kleur + File Info… Bestandsinfo… + Print… Afdrukken… + Edit Bewerken + Undo Ongedaan maken + Redo Opnieuw uitvoeren + Cut Knippen + Copy Kopiëren + Paste Plakken + Paste in Front Op de voorgrond plakken + Paste in Back Op de achtergrond plakken + Paste in Place Op dezelfde positie plakken + Paste on All Artboards Op alle tekengebieden plakken + Paste without Formatting Plakken zonder opmaak + Clear Wissen + Find and Replace… Zoeken en vervangen… + Find Next Volgende zoeken + Spelling Spelling + Auto Spell Check Automatische spellingcontrole + Check Spelling… Spelling controleren… + Edit Custom Dictionary… Aangepast woordenboek bewerken… + Edit Colors Kleuren bewerken + Recolor Artwork… Artwork herkleuren… + Recolor with Preset Herkleuren met voorinstelling + 1 Color Job… Opdracht in 1 kleur… + 2 Color Job… Opdracht in 2 kleuren… + 3 Color Job… Opdracht in 3 kleuren… + Color Library… Kleurbibliotheek… + Adjust Color Balance… Kleurbalans aanpassen… + Blend Front to Back Van voor naar achter mengen + Blend Horizontally Horizontaal mengen + Blend Vertically Verticaal mengen + Convert to CMYK Converteren naar CMYK + Convert to Grayscale Converteren naar grijswaarden + Convert to RGB Converteren naar RGB + Invert Colors Kleuren omkeren + Overprint Black… Zwart overprinten… + Saturate… Verzadigen… + Edit Original Origineel bewerken + Transparency Flattener Presets… Voorinstellingen voor transparantieafvlakking… + Print Presets… Afdrukvoorinstellingen… + PDF Presets… PDF-voorinstellingen… + Perspective Grid Presets… Voorinstellingen voor perspectiefraster… + Color Settings… Kleurinstellingen… + Assign Profile… Profiel toewijzen… + Keyboard Shortcuts… Sneltoetsen… + Import Shortcut Set Sneltoetsenset importeren + Import Shortcut Set… Sneltoetsenset importeren… + Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. Elk item in het bestand is gekoppeld aan een VectorCraft-gereedschap, -opdracht of -paneel. Controleer de overeenkomsten en geef aan wat er overblijft. + Keys Toetsen + The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. In het bestand worden toetsen en modificatietoetsen als codes geschreven. Controleer de voorgestelde betekenissen en geef aan welke codes nog niet bekend zijn. + Click, then press the key this code stands for (Esc cancels) Klik en druk vervolgens op de toets waar deze code voor staat (Esc annuleert) + Press a key… Druk op een toets… + from your earlier imports uit je eerdere imports + from the format's public description uit de openbare beschrijving van het formaat + not known yet nog niet bekend + Shortcuts Sneltoetsen + In the File In het bestand + In VectorCraft In VectorCraft + Needs Attention Only Alleen aandacht vereist + Ready Klaar + Needs a choice Keuze vereist + Needs a key Toets vereist + No equivalent Geen equivalent + Tools take a single key (optionally with Shift) Gereedschappen gebruiken één toets (eventueel met Shift) + A later row takes this shortcut Een latere rij gebruikt deze sneltoets + Same item as a later row Zelfde item als een latere rij + Start from VectorCraft Defaults Beginnen met de standaardinstellingen van VectorCraft + Add to the Current Shortcuts Toevoegen aan de huidige sneltoetsen + Remember My Choices Mijn keuzes onthouden + {ready} of {total} ready {ready} van {total} klaar + The file is too large to read in full, so only its first part is listed. Het bestand is te groot om volledig te lezen, dus alleen het eerste deel ervan wordt weergegeven. + Report copied Rapport gekopieerd + Line {line} Regel {line} + Line {line}: {name}: {status} Regel {line}: {name}: {status} + Read Shortcut Set File Sneltoetsensetbestand lezen + Import Matches Overeenkomsten importeren + Remember Import Matches Importovereenkomsten onthouden + Forget Import Matches Importovereenkomsten vergeten +@plural agrees with {n} default shortcut|agrees with {n} default shortcuts komt overeen met {n} standaardsneltoets|komt overeen met {n} standaardsneltoetsen +@plural {n} part of the file wasn't read|{n} parts of the file weren't read {n} deel van het bestand is niet gelezen|{n} delen van het bestand zijn niet gelezen +@msg This shortcut file is in a form VectorCraft can't read. Dit sneltoetsbestand heeft een vorm die VectorCraft niet kan lezen. +@msg not a shortcut set: the text doesn't start with a slash name and an opening brace geen sneltoetsenset: de tekst begint niet met een slash-naam en een openingsaccolade + Preferences… Voorkeuren… + Object Object + Transform Transformeren + Transform Again Opnieuw transformeren + Move… Verplaatsen… + Rotate… Roteren… + Reflect… Spiegelen… + Scale… Schalen… + Shear… Schuin zetten… + Transform Each… Elk afzonderlijk transformeren… + Reset Bounding Box Begrenzingskader herstellen + Arrange Schikken + Bring to Front Naar de voorgrond brengen + Bring Forward Naar voren brengen + Send Backward Naar achteren brengen + Send to Back Naar de achtergrond brengen + Send to Current Layer Naar de huidige laag brengen + Align Uitlijnen + Horizontal Align Left Links uitlijnen + Horizontal Align Center Horizontaal centreren + Horizontal Align Right Rechts uitlijnen + Vertical Align Top Bovenaan uitlijnen + Vertical Align Center Verticaal centreren + Horizontal & Vertical Align Center Horizontaal en verticaal centreren + Vertical Align Bottom Onderaan uitlijnen + Group Groeperen + Ungroup Groepering opheffen + Lock Vergrendelen + Selection Selectie + All Artwork Above Alle artwork erboven + Other Layers Andere lagen + Unlock All Alles ontgrendelen + Hide Verbergen + Show All Alles tonen + Expand… Uitbreiden… + Expand Appearance Uiterlijk uitbreiden + Crop Image Afbeelding bijsnijden + Mask Masker + Rasterize… Rasteriseren… + Create Gradient Mesh… Verloopmesh maken… + Create Object Mosaic… Objectmozaïek maken… + Vector Halftone… Vectorhalftoon… + Create Trim Marks Afsnijtekens maken + Flatten Transparency… Transparantie afvlakken… + Make Pixel Perfect Pixelperfect maken + Slice Slice + Make Maken + Make and Expand Maken en uitbreiden + Release Vrijgeven + Create from Guides Maken van hulplijnen + Create from Selection Maken van selectie + Duplicate Slice Slice dupliceren + Combine Slices Slices combineren + Divide Slices… Slices verdelen… + Delete All Alles verwijderen + Slice Options… Sliceopties… + Clip to Artboard Bijsnijden tot tekengebied + Path Pad + Join Verbinden + Average… Gemiddelde… + Outline Stroke Contour omzetten + Offset Path… Pad verschuiven… + Reverse Path Direction Padrichting omkeren + Simplify… Vereenvoudigen… + Add Anchor Points Ankerpunten toevoegen + Remove Anchor Points Ankerpunten verwijderen + Divide Objects Below Objecten eronder verdelen + Split Into Grid… Opsplitsen in raster… + Clean Up… Opschonen… + Shape Vorm + Convert to Shape Converteren naar vorm + Expand Shape Vorm uitbreiden + Pattern Patroon + Edit Pattern Patroon bewerken + Tile Edge Color… Tegelrandkleur… + Repeat Herhalen + Radial Radiaal + Grid Raster + Mirror Spiegelen + Options… Opties… + Blend Vermengen + Blend Options… Vermengopties… + Expand Uitbreiden + Replace Spine Rug vervangen + Reverse Spine Rug omkeren + Reverse Front to Back Van voor naar achter omkeren + Envelope Distort Envelopvervorming + Make with Warp… Maken met warp… + Reset with Warp… Herstellen met warp… + Make with Mesh… Maken met mesh… + Reset with Mesh… Herstellen met mesh… + Make with Top Object Maken met bovenste object + Envelope Options… Envelopopties… + Edit Contents Inhoud bewerken + Edit Envelope Envelop bewerken + Perspective Perspectief + Attach to Active Plane Koppelen aan actief vlak + Release with Perspective Vrijgeven met perspectief + Move Plane to Match Object Vlak naar object verplaatsen + Edit Text Tekst bewerken + Live Paint Live Paint + Merge Samenvoegen + Gap Options… Tussenruimteopties… + Image Trace Afbeelding traceren + Image Trace (Make and Expand) Afbeelding traceren (maken en uitbreiden) + Release Image Trace Tracering vrijgeven + Text Wrap Tekstomloop + Make Text Wrap Tekstomloop maken + Release Text Wrap Tekstomloop vrijgeven + Text Wrap Options… Tekstomloopopties… + Clipping Mask Knipmasker + Edit Clipping Path Knippad bewerken + Compound Path Samengesteld pad + Artboards Tekengebieden + Convert to Artboards Converteren naar tekengebieden + Rearrange All Artboards… Alle tekengebieden herschikken… + Fit to Artwork Bounds Op artworkgrenzen passend maken + Fit to Selected Art Op geselecteerd artwork passend maken + Graph Grafiek + Type… Type… + Data… Gegevens… + Design… Ontwerp… + Graph Design Grafiekontwerp + Graph Marker Grafiekmarkering + Column… Kolom… + Marker… Markering… + Collect for Export Verzamelen voor export + As Single Asset Als één asset + As Multiple Assets Als meerdere assets + Plug-ins Plug-ins + No filter plug-ins installed Geen filterplug-ins geïnstalleerd + Install Plug-in… Plug-in installeren… + Reload Plug-ins Plug-ins opnieuw laden + Type Type + Font Lettertype + Recent Fonts Recente lettertypen + Size Grootte + Glyphs Glyphs + Insert Special Character Speciaal teken invoegen + Bullet Opsommingsteken + Copyright Symbol Copyrightteken + Ellipsis Beletselteken + Paragraph Symbol Alineateken + Registered Trademark Symbol Geregistreerd handelsmerkteken + Section Symbol Sectieteken + Trademark Symbol Handelsmerkteken + Em Dash Gedachtestreep + En Dash Halve gedachtestreep + Discretionary Hyphen Discretionair afbreekteken + Nonbreaking Hyphen Vast afbreekteken + Double Left Quotation Marks Dubbele aanhalingstekens links + Double Right Quotation Marks Dubbele aanhalingstekens rechts + Single Left Quotation Mark Enkel aanhalingsteken links + Single Right Quotation Mark Enkel aanhalingsteken rechts + Insert Whitespace Character Witruimteteken invoegen + Em Space Em-spatie + En Space En-spatie + Hair Space Haarspatie + Sixth Space Zesde spatie + Thin Space Dunne spatie + Nonbreaking Space Vaste spatie + Figure Space Cijferspatie + Punctuation Space Leestekenspatie + Third Space Derde spatie + Quarter Space Kwart spatie + Insert Break Character Onderbrekingsteken invoegen + Forced Line Break Gedwongen regeleinde + Tab Tab + Convert To Area Type Converteren naar vlaktekst + Convert To Point Type Converteren naar punttekst + Area Type Options… Vlaktekstopties… + Type on a Path Tekst op pad + Area Type Vlaktekst + Rainbow Regenboog + Skew Schuin + 3D Ribbon 3D-lint + Stair Step Trap + Gravity Zwaartekracht + Type on a Path Options… Opties voor tekst op pad… + Threaded Text Gekoppelde tekst + Create Maken + Release Selection Selectie vrijgeven + Remove Threading Koppeling opheffen + Fit Headline Kop passend maken + Find Font… Lettertype zoeken… + Change Case Hoofdlettergebruik wijzigen + UPPERCASE HOOFDLETTERS + lowercase kleine letters + Title Case Koptitel + Sentence case Zinsvorm + Smart Punctuation… Slimme interpunctie… + Create Outlines Omzetten in curven + Optical Margin Alignment Optische marge-uitlijning + Fill with Placeholder Text Vullen met plaatshoudertekst + Insert Inline Symbol Inline-symbool invoegen + Show Hidden Characters Verborgen tekens tonen + Bold Vet + Italic Cursief + Hide Hidden Characters Verborgen tekens verbergen + Type Orientation Typeoriëntatie + Horizontal Horizontaal + Vertical Verticaal + Select Selecteren + All Alles + All on Active Artboard Alles op actieve tekengebied + Deselect Deselecteren + Reselect Opnieuw selecteren + Inverse Omgekeerde + Next Object Above Volgende object erboven + Next Object Below Volgende object eronder + Same Zelfde + Appearance Uiterlijk + Appearance Attribute Uiterlijkkenmerk + Blending Mode Vermengingsmodus + Fill & Stroke Vulling en contour + Fill Color Vulkleur + Opacity Dekking + Stroke Color Contourkleur + Stroke Weight Contourdikte + Graphic Style Grafische stijl + Symbol Instance Symboolinstantie + Font Family Lettertypefamilie + Font Family & Style Lettertypefamilie en -stijl + Font Family, Style & Size Lettertypefamilie, -stijl en -grootte + Font Size Lettertypegrootte + Text Fill Color Tekstvulkleur + Text Stroke Color Tekstcontourkleur + All on Same Layers Alles op dezelfde lagen + Direction Handles Richtingsgrepen + Brush Strokes Penseelstreken + Bristle Brush Strokes Borstelpenseelstreken + Clipping Masks Knipmaskers + Slices Slices + Stray Points Losse punten + Open Paths Open paden + All Text Objects Alle tekstobjecten + Point Text Objects Punttekstobjecten + Area Text Objects Vlaktekstobjecten + Start Global Edit Globale bewerking starten + Save Selection… Selectie opslaan… + Edit Selection… Selectie bewerken… + Effect Effect + Apply Last Effect Laatste effect toepassen + Last Effect… Laatste effect… + Document Raster Effects Settings… Instellingen voor documentrastereffecten… + Vector Effects Vectoreffecten + 3D and Materials 3D en materialen + Extrude & Bevel… Extruderen en afschuinen… + Revolve… Draaien… + Inflate… Opblazen… + Materials… Materialen… + Color Adjustments Kleuraanpassingen + Brightness/Contrast… Helderheid/contrast… + Curves… Curves… + Hue/Saturation… Kleurtoon/verzadiging… + Levels… Niveaus… + Shift to Color… Naar kleur verschuiven… + Temperature/Tint… Temperatuur/tint… + Rectangle… Rechthoek… + Rounded Rectangle… Afgeronde rechthoek… + Ellipse… Ellips… + Crop Marks Snijtekens + Distort & Transform Vervormen en transformeren + Free Distort… Vrij vervormen… + Pucker & Bloat… Samentrekken en uitzetten… + Roughen… Ruwer maken… + Transform… Transformeren… + Tweak… Fijnstellen… + Twist… Torderen… + Zig Zag… Zigzag… + Pathfinder Padvinder + Add Toevoegen +@id pathfinder.add pathfinder.add + Intersect Doorsnede + Exclude Uitsluiten + Subtract Aftrekken + Minus Back Min achterkant + Divide Verdelen + Trim Afsnijden + Crop Bijsnijden + Outline Omtrek + Stylize Stileren + Round Corners… Hoeken afronden… + Scribble… Gekrabbel… + Drop Shadow… Slagschaduw… + Inner Glow… Inwendige gloed… + Outer Glow… Uitwendige gloed… + Feather… Doezelen… + SVG Filters SVG-filters + Apply SVG Filter… SVG-filter toepassen… + Warp Warp + Arc… Boog… + Arc Lower… Onderboog… + Arc Upper… Bovenboog… + Arch… Gewelf… + Bulge… Bolling… + Shell Lower… Onderschulp… + Shell Upper… Bovenschulp… + Flag… Vlag… + Wave… Golf… + Fish… Vis… + Rise… Stijging… + Fisheye… Vissenoog… + Squeeze… Knijpen… + Raster Effects Rastereffecten + Blur Vervagen + Gaussian Blur… Gaussiaanse vervaging… + View Beeld + Preview Voorbeeld + Overprint Preview Overprintvoorbeeld + Pixel Preview Pixelvoorbeeld + Trim View Bijsnijweergave + Presentation Mode Presentatiemodus + Screen Mode Schermmodus + Normal Screen Mode Standaardschermmodus + Full Screen Mode with Menu Bar Volledig scherm met menubalk + Full Screen Mode Volledig scherm + Proof Setup Kleurproef instellen + Working CMYK CMYK-werkruimte + RGB RGB + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + VectorCraft Generic CMYK (SWOP-like) Generieke VectorCraft-CMYK (SWOP-achtig) + Legacy Macintosh RGB (Gamma 1.8) Verouderde Macintosh-RGB (Gamma 1.8) + Internet Standard RGB (sRGB) Internetstandaard-RGB (sRGB) + Monitor RGB Monitor-RGB + Color blindness – Protanopia-type Kleurenblindheid – type protanopie + Color blindness – Deuteranopia-type Kleurenblindheid – type deuteranopie + Customize… Aanpassen… + Proof Colors Kleurproef + Zoom In Inzoomen + Zoom Out Uitzoomen + Fit Artboard in Window Tekengebied in venster passend maken + Fit All in Window Alles in venster passend maken + Actual Size Werkelijke grootte + Reset Rotate View Rotatieweergave herstellen + Hide Edges Randen verbergen + Show Edges Randen tonen + Hide Artboards Tekengebieden verbergen + Show Artboards Tekengebieden tonen + Show Print Tiling Afdruktegels tonen + Hide Print Tiling Afdruktegels verbergen + Hide Slices Slices verbergen + Show Slices Slices tonen + Lock Slices Slices vergrendelen + Rulers Linialen + Show Rulers Linialen tonen + Hide Rulers Linialen verbergen + Change to Global Rulers Omschakelen naar globale linialen + Show Video Rulers Videolinialen tonen + Hide Bounding Box Begrenzingskader verbergen + Show Bounding Box Begrenzingskader tonen + Show Transparency Grid Transparantieraster tonen + Hide Transparency Grid Transparantieraster verbergen + Hide Text Threads Tekstkoppelingen verbergen + Show Text Threads Tekstkoppelingen tonen + Hide Gradient Annotator Verloopaanduiding verbergen + Show Gradient Annotator Verloopaanduiding tonen + Hide Corner Widget Hoekwidget verbergen + Show Corner Widget Hoekwidget tonen + Guides Hulplijnen + Hide Guides Hulplijnen verbergen + Show Guides Hulplijnen tonen + Lock Guides Hulplijnen vergrendelen + Unlock Guides Hulplijnen ontgrendelen + Make Guides Hulplijnen maken + Release Guides Hulplijnen vrijgeven + Clear Guides Hulplijnen wissen + Smart Guides Slimme hulplijnen + Perspective Grid Perspectiefraster + Show Grid Raster tonen + Hide Grid Raster verbergen + Snap to Grid Uitlijnen op raster + Lock Grid Raster vergrendelen + Unlock Grid Raster ontgrendelen + Lock Station Point Standpunt vergrendelen + Define Grid… Raster definiëren… + One Point Perspective Eénpuntsperspectief + Two Point Perspective Tweepuntsperspectief + Three Point Perspective Driepuntsperspectief + Save Grid as Preset… Raster opslaan als voorinstelling… + Snap to Pixel Uitlijnen op pixel + Precise Cursors Nauwkeurige cursors + Snap to Point Uitlijnen op punt + Snap to Glyph Uitlijnen op glyph + New View… Nieuwe weergave… + Edit Views… Weergaven bewerken… + Saved View 1 Opgeslagen weergave 1 + Saved View 2 Opgeslagen weergave 2 + Saved View 3 Opgeslagen weergave 3 + Saved View 4 Opgeslagen weergave 4 + Saved View 5 Opgeslagen weergave 5 + Saved View 6 Opgeslagen weergave 6 + Saved View 7 Opgeslagen weergave 7 + Saved View 8 Opgeslagen weergave 8 + Saved View 9 Opgeslagen weergave 9 + Saved View 10 Opgeslagen weergave 10 + Window Venster + New Window Nieuw venster + Cascade Trapsgewijs schikken + Tile Naast elkaar schikken + Float in Window In venster zweven + Consolidate All Windows Alle vensters samenvoegen + Workspace Werkruimte + Reset Essentials Essentials herstellen + Reset Automation Automation herstellen + Reset Essentials Classic Essentials Classic herstellen + Reset Layout Layout herstellen + Reset Painting Painting herstellen + Reset Printing and Proofing Printing and Proofing herstellen + Reset Tracing Tracing herstellen + Reset Typography Typography herstellen + Reset Web Web herstellen + New Workspace… Nieuwe werkruimte… + Manage Workspaces… Werkruimten beheren… + Control Bediening + Contextual Task Bar Contextuele taakbalk + Pin Bar Position Balkpositie vastzetten + Reset Bar Position Balkpositie herstellen + Tools Gereedschappen + Toolbars Werkbalken + Advanced Geavanceerd + Double Column Dubbele kolom + Actions Acties + Asset Export Assetexport + Attributes Kenmerken + Brushes Penseel + Color Kleur + Color Guide Kleurhulp + Color Themes Kleurthema's + CSS Properties CSS-eigenschappen + Document Info Documentinfo + Flattener Preview Afvlakvoorbeeld + Gradient Verloop + Graphic Styles Grafische stijlen + Graphic Styles (4) Grafische stijlen (4) + History Historie + Info Info + Layers Lagen + 1 Layer 1 laag + Libraries Bibliotheken + Links Koppelingen + {count} linked image(s) changed on disk. Show the new versions? {count} gekoppelde afbeelding(en) gewijzigd op de schijf. Nieuwe versies tonen? + Update Changed Links Gewijzigde koppelingen bijwerken +@msg Updated {_1} linked file(s) changed on disk Bijgewerkt: {_1} gekoppelde bestand(en) gewijzigd op de schijf + Magic Wand Magische toverstaf + Navigator Navigator + Pattern Options Patroonopties + Properties Eigenschappen + Separations Preview Scheidingsvoorbeeld + Stroke Contour + SVG Interactivity SVG-interactiviteit + Swatches Stalen + Symbols Symbolen + Transparency Transparantie + Character Teken + Character Styles Tekenstijlen + Paragraph Alinea + Paragraph Styles Alineastijlen + Tabs Tabs + Variables Variabelen + New Variable… Nieuwe variabele… + Unbind Variable Variabele loskoppelen + Delete Variable Variabele verwijderen + New Data Set… Nieuwe gegevensset… + Edit Data Set… Gegevensset bewerken… + Delete Data Set Gegevensset verwijderen + Next Data Set Volgende gegevensset + Previous Data Set Vorige gegevensset + Make Text Dynamic Tekst dynamisch maken + Make Visibility Dynamic Zichtbaarheid dynamisch maken + Capture Data Set Gegevensset vastleggen + Update Data Set Gegevensset bijwerken + Rename Data Set… Gegevensset hernoemen… + Variable Options… Variabeleopties… + Brush Libraries Penseelbibliotheken + Built-in Libraries Ingebouwde bibliotheken + User Defined Gebruikergedefinieerd + Other Library… Andere bibliotheek… + Graphic Style Libraries Bibliotheken met grafische stijlen + Shadows and Glows Schaduwen en gloed + Outlines and Rules Omtrekken en lijnen + Hand-Drawn Handgetekend + Gradient Finishes Verloopafwerkingen + Shape Effects Vormeffecten + Blends and Transparency Vermengingen en transparantie + Save Graphic Style Library… Bibliotheek met grafische stijlen opslaan… + Swatch Libraries Staalenbibliotheken + Default Swatches Standaardstalen + Web Safe 216 Webveilig 216 + Grays and Neutrals Grijzen en neutrale kleuren + Earth Tones Aardetinten + Skin Tone Ramps Huidtintenverlopen + Pastels Pasteltinten + Brights Felle kleuren + Metallic Gradients Metallic verlopen + Perceptual Scales Perceptuele schalen + Harmony Sets Harmoniesets + Gradients Verlopen + Spectrum Gradients Spectrumverlopen + Sky Gradients Luchtverlopen + Neutral Gradients Neutrale verlopen + Duotone Gradients Duotoonverlopen + Radial Glows Radiale gloed + Save Swatch Library… Staalenbibliotheek opslaan… + Symbol Libraries Symboolbibliotheken + Help Help + ArtCraft Website ArtCraft-website + VectorCraft on getartcraft.com VectorCraft op getartcraft.com + VectorCraft on GitHub VectorCraft op GitHub + Search Commands… Opdrachten zoeken… + VectorCraft Help… VectorCraft-help… + Auto Color Automatische kleur + Automatic Automatisch + Isolate Selected Group Geselecteerde groep isoleren + Exit Isolation Mode Isolatiemodus verlaten + Make Clipping Mask Knipmasker maken + Release Clipping Mask Knipmasker vrijgeven + Make Compound Path Samengesteld pad maken + Release Compound Path Samengesteld pad vrijgeven + Select All Alles selecteren + Collapse Panels to Icons Panelen samenvouwen tot pictogrammen + Collapse to Icons Samenvouwen tot pictogrammen + Expand Panels Panelen uitvouwen + About Over + Contributors Bijdragers + Models Modellen + Username Gebruikersnaam + Display name Weergavenaam + Real name Echte naam + Sort Sorteren + Reverse the order Volgorde omkeren + Grab bag Grabbelton + Table Tabel + Name (A–Z) Naam (A–Z) + Merged PRs Samengevoegde PR's + Commits Commits + Lines added Toegevoegde regels + Lines deleted Verwijderde regels + Line delta Regelverschil + Binary assets added Toegevoegde binaire assets + Binary assets removed Verwijderde binaire assets + First commit Eerste commit + Last commit Laatste commit + First Eerste + Last Laatste + {people} contributors · {commits} commits {people} bijdragers · {commits} commits + No contributor data was built into this copy. Er zijn geen bijdragergegevens in deze kopie opgenomen. + No model credits were built into this copy. Er zijn geen modelcredits in deze kopie opgenomen. + Company Bedrijf + Version Versie + % of all commits % van alle commits + Lines +/− Regels +/− + Drag to move Slepen om te verplaatsen + Drag to move out of the set Slepen om uit de set te halen + Drag to float as a toolbar Slepen om als losse werkbalk te laten zweven + Put back in the toolbar Terug in de werkbalk zetten + Float Panel Paneel laten zweven + Dock Panel Paneel dokken + Float Tool Group Gereedschapsgroep laten zweven + Services Diensten + Hide VectorCraft VectorCraft verbergen + Hide Others Anderen verbergen + Minimize Minimaliseren + Zoom Zoom + Bring All to Front Alles naar de voorgrond brengen + Settings Instellingen + General Algemeen + Selection & Anchor Display Selectie- en ankerweergave + Units Eenheden + Guides & Grid Hulplijnen en raster + Hyphenation Afbreking + Performance & Storage Prestaties en opslag + User Interface Gebruikersinterface + Performance Prestaties + File Handling Bestandsbeheer + Clipboard Handling Klembordbeheer + Appearance of Black Weergave van zwart + Devices Apparaten + A file dialog is open: choose there first Er is een bestandsdialoogvenster geopend: kies daar eerst + The document was closed while its file dialog was open Het document is gesloten terwijl het bestandsdialoogvenster open was + The dialog was closed before its file dialog answered Het dialoogvenster is gesloten voordat het bestandsdialoogvenster antwoordde + Shaper Shaper + Expand Shaper Group Shapergroep uitbreiden + Release Shaper Group Shapergroep vrijgeven + Select Shaper Face Shapervlak selecteren + Constrain Beperken + Free Transform Vrij transformeren + Perspective Distort Perspectief vervormen + Radial Blur… Radiaal vervagen… + Smart Blur… Slim vervagen… + Sharpen Verscherpen + Unsharp Mask… Onscherp maskeren… + Pixelate Pixeleren + Color Halftone… Kleurhalftoon… + Crystallize… Kristalliseren… + Mezzotint… Mezzotint… + Pointillize… Pointilleren… + Video Video + De-Interlace… De-interlacen… + NTSC Colors NTSC-kleuren + RGB: RGB: + CMYK: CMYK: + CMYK CMYK + Texture Textuur + Craquelure… Craquelure… + Grain… Korrel… + Mosaic Tiles… Mozaïektegels… + Patchwork… Patchwork… + Stained Glass… Glas in lood… + Texturizer… Texturizer… + System Title Bar Systeemtitelbalk + Exit Afsluiten + Distort Vervormen + Diffuse Glow… Diffuse gloed… + Glass… Glas… + Ocean Ripple… Oceaanrimpeling… + Rename Data Set Gegevensset hernoemen + Rename Variable Variabele hernoemen + Activate Panel Paneel activeren + Change Panel Layout Paneelindeling wijzigen + Close Panel Paneel sluiten + Move Panel Paneel verplaatsen + Group with Groeperen met + Resize panels Panelen van formaat wijzigen + Resize panel window Paneelvenster van formaat wijzigen + Lighting Belichting + Revolve angle Draaihoek + Axis side Aszijde + Rotation X Rotatie X + Rotation Y Rotatie Y + Rotation Z Rotatie Z + Shading Arcering + Intensity Intensiteit + Segments Segmenten + Show axis As tonen + Reset all Alles herstellen + Reset to default Naar standaard herstellen + Reset all Revolve settings to their defaults. Stelt alle draai-instellingen opnieuw in op de standaardwaarden. + Drag values or labels. Double-click a value to type. Sleep waarden of labels. Dubbelklik op een waarde om te typen. + Drag rings to rotate. Shift snaps to 15°. Sleep de ringen om te roteren. Shift lijnt uit op 15°. + Drag the light, or its rings. Shift snaps to 15°. Sleep het licht of de ringen ervan. Shift lijnt uit op 15°. +lighting Light Licht + Keep visible surfaces only when expanding Alleen zichtbare oppervlakken behouden bij het uitbreiden + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Bedekte geometrie wordt weggesneden bij Uiterlijk uitbreiden. Het livevoorbeeld houdt het volledige oppervlak. Transparante verven en oppervlakken die de zichtbaarheidsgrenzen overschrijden behouden alle vlakken. + Left Links + Right Rechts + Offset Offset + Rotation Rotatie + Cancel Annuleren + Accented Edges… Geaccentueerde randen… + Angled Strokes… Schuine streken… + Crosshatch… Kruisarcering… + Dark Strokes… Donkere streken… + Ink Outlines… Inktcontouren… + Spatter… Spatten… + Sprayed Strokes… Gespoten streken… + Sumi-e… Sumi-e… + Vertical Guide Verticale hulplijn + Horizontal Guide Horizontale hulplijn + Align center Centreren + Align left Links uitlijnen + Align right Rechts uitlijnen + Browser Browser + Color Guide Options… Kleurhulpopties… + Color Picker… Kleurkiezer… + Coming soon — tracked in the parity plan Binnenkort beschikbaar — opgenomen in het pariteitsplan + Command Dialog Opdrachtdialoogvenster + Copy Selected Style Geselecteerde stijl kopiëren + Corners… Hoeken… + Define Perspective Grid… Perspectiefraster definiëren… + Delete Workspace Werkruimte verwijderen + Document Raster Effects Settings Dialog Dialoogvenster rastereffectinstellingen van document + Drop Shadow Slagschaduw + DXF Options Dialog Dialoogvenster DXF-opties + Edit Toolbar Werkbalk bewerken + Edit Views Weergaven bewerken + Effect… Effect… + EPS Options Dialog Dialoogvenster EPS-opties + Expand Layers Panel Rows Rijen in het Lagen-paneel uitvouwen + Export CSS… CSS exporteren… + Export Keyboard Shortcuts… Sneltoetsen exporteren… + File Info Dialog Dialoogvenster bestandsinfo + Go to Artboard Ga naar tekengebied + Graphic Style Options… Opties voor grafische stijl… + Symbol Options Symboolopties + Symbol Options… Symboolopties… + Home Home + Import Keyboard Shortcuts… Sneltoetsen importeren… + Justify all lines Alle regels uitvullen + Justify with last line aligned left Uitvullen met laatste regel links uitgelijnd + Keyboard Shortcut Conflicts Sneltoetsconflicten + Keyboard Shortcut Set Sneltoetsset + Interface Language Interfacetaal + Last Effect: {name} Laatste effect: {name} + Last Effect: {} Laatste effect: {} + Limit Color Guide to Library Kleurengids beperken tot bibliotheek + List Keyboard Shortcuts Sneltoetsen weergeven + List Workspaces Werkruimten weergeven + Menu Dialog Menudialoogvenster + Merge Graphic Styles… Grafische stijlen samenvoegen… + New Color Group… Nieuwe kleurgroep… + New Layer Options… Opties voor nieuwe laag… + New Swatch… Nieuw staal… + No saved views (View → New View…) Geen opgeslagen weergaven (Weergave → Nieuwe weergave…) + One Point Perspective Preset 1 Voorinstelling perspectief met één vluchtpunt 1 + One Point Perspective Preset 2 Voorinstelling perspectief met één vluchtpunt 2 + One Point Perspective Preset 3 Voorinstelling perspectief met één vluchtpunt 3 + One Point Perspective Preset 4 Voorinstelling perspectief met één vluchtpunt 4 + One Point Perspective Preset 5 Voorinstelling perspectief met één vluchtpunt 5 + Open Recent File 1 Recent bestand 1 openen + Open Recent File 10 Recent bestand 10 openen + Open Recent File 11 Recent bestand 11 openen + Open Recent File 12 Recent bestand 12 openen + Open Recent File 13 Recent bestand 13 openen + Open Recent File 14 Recent bestand 14 openen + Open Recent File 15 Recent bestand 15 openen + Open Recent File 16 Recent bestand 16 openen + Open Recent File 17 Recent bestand 17 openen + Open Recent File 18 Recent bestand 18 openen + Open Recent File 19 Recent bestand 19 openen + Open Recent File 2 Recent bestand 2 openen + Open Recent File 20 Recent bestand 20 openen + Open Recent File 21 Recent bestand 21 openen + Open Recent File 22 Recent bestand 22 openen + Open Recent File 23 Recent bestand 23 openen + Open Recent File 24 Recent bestand 24 openen + Open Recent File 25 Recent bestand 25 openen + Open Recent File 26 Recent bestand 26 openen + Open Recent File 27 Recent bestand 27 openen + Open Recent File 28 Recent bestand 28 openen + Open Recent File 29 Recent bestand 29 openen + Open Recent File 3 Recent bestand 3 openen + Open Recent File 30 Recent bestand 30 openen + Open Recent File 4 Recent bestand 4 openen + Open Recent File 5 Recent bestand 5 openen + Open Recent File 6 Recent bestand 6 openen + Open Recent File 7 Recent bestand 7 openen + Open Recent File 8 Recent bestand 8 openen + Open Recent File 9 Recent bestand 9 openen + Options for Selection… Opties voor selectie… + Panel Options… Paneelopties… + Panels Panelen + Perspective Plane Options… Opties voor perspectiefvlak… + Placement Options… Plaatsingsopties… + Plug-in… Plug-in… + Preview in Browser Voorbeeld in browser + Printer Setup… Printerinstellingen… + Printers Printers + Recent File Recent bestand + Recent Font Recent lettertype + Recent Font 1 Recent lettertype 1 + Recent Font 10 Recent lettertype 10 + Recent Font 11 Recent lettertype 11 + Recent Font 12 Recent lettertype 12 + Recent Font 13 Recent lettertype 13 + Recent Font 14 Recent lettertype 14 + Recent Font 15 Recent lettertype 15 + Recent Font 2 Recent lettertype 2 + Recent Font 3 Recent lettertype 3 + Recent Font 4 Recent lettertype 4 + Recent Font 5 Recent lettertype 5 + Recent Font 6 Recent lettertype 6 + Recent Font 7 Recent lettertype 7 + Recent Font 8 Recent lettertype 8 + Recent Font 9 Recent lettertype 9 + Redo {name} Opnieuw: {name} + Redo {} Opnieuw: {} + Missing Fonts Dialog Dialoogvenster ontbrekende lettertypen + Find Fonts in Folder… Lettertypen in map zoeken… + Rename Workspace Werkruimte hernoemen + Reset Keyboard Shortcuts Sneltoetsen herstellen + Reset {name} {name} herstellen + Reset {} {} herstellen + Save Document Info… Documentinfo opslaan… + Save PDF Dialog Dialoogvenster PDF opslaan + Saved Selection 1 Opgeslagen selectie 1 + Saved Selection 10 Opgeslagen selectie 10 + Saved Selection 11 Opgeslagen selectie 11 + Saved Selection 12 Opgeslagen selectie 12 + Saved Selection 13 Opgeslagen selectie 13 + Saved Selection 14 Opgeslagen selectie 14 + Saved Selection 15 Opgeslagen selectie 15 + Saved Selection 16 Opgeslagen selectie 16 + Saved Selection 17 Opgeslagen selectie 17 + Saved Selection 18 Opgeslagen selectie 18 + Saved Selection 19 Opgeslagen selectie 19 + Saved Selection 2 Opgeslagen selectie 2 + Saved Selection 20 Opgeslagen selectie 20 + Saved Selection 21 Opgeslagen selectie 21 + Saved Selection 22 Opgeslagen selectie 22 + Saved Selection 23 Opgeslagen selectie 23 + Saved Selection 24 Opgeslagen selectie 24 + Saved Selection 25 Opgeslagen selectie 25 + Saved Selection 3 Opgeslagen selectie 3 + Saved Selection 4 Opgeslagen selectie 4 + Saved Selection 5 Opgeslagen selectie 5 + Saved Selection 6 Opgeslagen selectie 6 + Saved Selection 7 Opgeslagen selectie 7 + Saved Selection 8 Opgeslagen selectie 8 + Saved Selection 9 Opgeslagen selectie 9 + Select Tool Gereedschap selecteren + Set Keyboard Shortcut Sneltoets instellen + Set Zoom Zoom instellen + Show Package Pakket tonen + Show Panel Paneel tonen + Spot Colors… Steunkleuren… + Swatch Options… Staalopties… + Three Point Perspective Preset 1 Voorinstelling perspectief met drie vluchtpunten 1 + Three Point Perspective Preset 2 Voorinstelling perspectief met drie vluchtpunten 2 + Three Point Perspective Preset 3 Voorinstelling perspectief met drie vluchtpunten 3 + Three Point Perspective Preset 4 Voorinstelling perspectief met drie vluchtpunten 4 + Three Point Perspective Preset 5 Voorinstelling perspectief met drie vluchtpunten 5 + Tool Option Gereedschapsoptie + Tool Options… Gereedschapsopties… + Toolbar: Advanced / Basic Werkbalk: Geavanceerd / Eenvoudig + Basic Eenvoudig + Toolbar: Single/Double Column Werkbalk: Enkele/dubbele kolom + Toolbar: Add Tool Werkbalk: gereedschap toevoegen + Toolbar: Remove Tool Werkbalk: gereedschap verwijderen + Toolbar: Reset Werkbalk: herstellen + Two Point Perspective Preset 1 Voorinstelling perspectief met twee vluchtpunten 1 + Two Point Perspective Preset 2 Voorinstelling perspectief met twee vluchtpunten 2 + Two Point Perspective Preset 3 Voorinstelling perspectief met twee vluchtpunten 3 + Two Point Perspective Preset 4 Voorinstelling perspectief met twee vluchtpunten 4 + Two Point Perspective Preset 5 Voorinstelling perspectief met twee vluchtpunten 5 + Undo {name} Ongedaan maken: {name} + Undo {} Ongedaan maken: {} + User Graphic Style Library 1 Grafische stijlbibliotheek van gebruiker 1 + User Graphic Style Library 10 Grafische stijlbibliotheek van gebruiker 10 + User Graphic Style Library 2 Grafische stijlbibliotheek van gebruiker 2 + User Graphic Style Library 3 Grafische stijlbibliotheek van gebruiker 3 + User Graphic Style Library 4 Grafische stijlbibliotheek van gebruiker 4 + User Graphic Style Library 5 Grafische stijlbibliotheek van gebruiker 5 + User Graphic Style Library 6 Grafische stijlbibliotheek van gebruiker 6 + User Graphic Style Library 7 Grafische stijlbibliotheek van gebruiker 7 + User Graphic Style Library 8 Grafische stijlbibliotheek van gebruiker 8 + User Graphic Style Library 9 Grafische stijlbibliotheek van gebruiker 9 + User Swatch Library 1 Staalbibliotheek van gebruiker 1 + User Swatch Library 10 Staalbibliotheek van gebruiker 10 + User Swatch Library 2 Staalbibliotheek van gebruiker 2 + User Swatch Library 3 Staalbibliotheek van gebruiker 3 + User Swatch Library 4 Staalbibliotheek van gebruiker 4 + User Swatch Library 5 Staalbibliotheek van gebruiker 5 + User Swatch Library 6 Staalbibliotheek van gebruiker 6 + User Swatch Library 7 Staalbibliotheek van gebruiker 7 + User Swatch Library 8 Staalbibliotheek van gebruiker 8 + User Swatch Library 9 Staalbibliotheek van gebruiker 9 + View {n} Weergave {n} + Width Point Edit… Breedtepunt bewerken… + to search every command om elke opdracht te zoeken + Anchor Point Ankerpunt + Anchors: Ankerpunten: + Anchors Ankerpunten + Click Klik + Alt+Click Alt+klik + Shift+Click Shift+klik + Click an anchor point Klik op een ankerpunt + Click or drag Klik of sleep + Click the first point Klik op het eerste punt + Connect Selected End Points Geselecteerde eindpunten verbinden + Convert Selected Anchor Points to Corner Geselecteerde ankerpunten omzetten in hoekpunten + Convert Selected Anchor Points to Smooth Geselecteerde ankerpunten omzetten in vloeiende punten + Convert: Omzetten: + Double-click Dubbelklik + Drag Sleep + Alt+Drag Alt+sleep + Shift+Drag Shift+sleep + Drag a handle Sleep een handgreep + Enter Enter + Esc Esc + Fit All Alles inpassen + Fit on Screen Passend op scherm + Help › Search Commands… Help › Opdrachten zoeken… + Name* @ 66.67 % (RGB/Preview) Naam* @ 66.67 % (RGB/Voorbeeld) + Name* @ 66.67% (RGB/Preview) Naam* @ 66.67% (RGB/Voorbeeld) + No Document Geen document + Press Druk op + Search commands and tools Opdrachten en gereedschappen zoeken + Style: Stijl: + Switch workspace Werkruimte wisselen + Show all open documents Alle geopende documenten tonen + Use Gebruik + the object to select | op het object om te selecteren | + to select multiple objects | om meerdere objecten te selecteren | + the object to duplicate het object om het te dupliceren + an anchor or path segment to select it | op een ankerpunt of padsegment om het te selecteren | + to move | om te verplaatsen | + to add om toe te voegen + to add a corner point | om een hoekpunt toe te voegen | + to add a smooth point | om een vloeiend punt toe te voegen | + to close | om te sluiten | + to finish om te voltooien + to add smooth points | om vloeiende punten toe te voegen | + to toggle corner | om te schakelen tussen hoek en vloeiend | + to create point type | om punttype te maken | + to create area type | om vlaktype te maken | + to exit editing om het bewerken af te sluiten + to draw | om te tekenen | + to constrain proportions | om de verhoudingen te behouden | + from center | om vanuit het midden te tekenen | + for exact size voor exacte afmetingen + to set the reference point | om het referentiepunt in te stellen | + to transform | om te transformeren | + for exact values voor exacte waarden + to pan the view om de weergave te verschuiven + to zoom in | om in te zoomen | + to zoom out | om uit te zoomen | + to zoom into an area om in te zoomen op een gebied + Hold Houd + right or left to zoom in or out | naar rechts of links om in of uit te zoomen | + to keep zooming ingedrukt om door te zoomen + an object to copy its attributes | op een object om de eigenschappen ervan te kopiëren | + to apply the selection's to it | om de eigenschappen van de selectie erop toe te passen | + to sample a color om een kleur over te nemen + across a selected object to set the gradient direction over een geselecteerd object om de richting van het verloop in te stellen + to select an artboard | om een tekengebied te selecteren | + on the canvas to create one op het canvas om er een te maken + a stroke to add a width point | op een lijn om een breedtepunt toe te voegen | + to change one side | om één kant te wijzigen | + to select more points | om meer punten te selecteren | + a point to edit it | op een punt om het te bewerken | + to remove points om punten te verwijderen + the art to add a pin | op de illustratie om een speld toe te voegen | + to select more pins | om meer spelden te selecteren | + a pin to warp | een speld om te vervormen | + near a selected pin to rotate | vlak bij een geselecteerde speld om te draaien | + to remove pins om spelden te verwijderen + across paths to push them along | over paden om ze voort te duwen | + to size the brush ( om de penseelgrootte aan te passen ( + keeps its proportions) behoudt de verhoudingen) + over paths, and hold still to keep going | over paden en houd stil om door te gaan | + over paths to roughen their outlines | over paden om de omtrekken ruw te maken | + to move in perspective | om in perspectief te verplaatsen | + to copy | om te kopiëren | + while dragging to move perpendicular to the plane | terwijl je sleept om loodrecht op het vlak te verplaatsen | + to scale in perspective om in perspectief te schalen + an object, then another to blend them | op een object en daarna op een ander om er een overgang tussen te maken | + to blend from it | om er een overgang van te maken | + to set spacing and orientation om de tussenruimte en de oriëntatie in te stellen + Apply Toepassen + Change Screen Mode (F) Schermmodus wisselen (F) + Color (,) Kleur (,) + Draw Behind Achter tekenen + Draw Inside Binnen tekenen + Draw Normal Normaal tekenen + Expand: Uitvouwen: + Gradient (.) Verloop (.) + H: H: + Modify Wijzigen + Navigate Navigeren + None (/) Geen (/) + Select All Pins Alle spelden selecteren + Show Mesh Vlechtwerk tonen + Toggle single/double column Wisselen tussen enkele en dubbele kolom + W: W: + X: X: + Y: Y: + Drag to float the toolbar Sleep om de werkbalk zwevend te maken + Drag to the window's left edge to dock Sleep naar de linkerrand van het venster om te dokken + Crystallize Kristalliseren + Add Anchor Point Tool Ankerpunt toevoegen + Anchor Point Tool Ankerpunt-gereedschap + Arc Tool Boog-gereedschap + Area Graph Tool Vlakdiagram-gereedschap + Area Type Tool Vlaktype-gereedschap + Artboard Tool Tekengebied-gereedschap + Bar Graph Tool Staafdiagram-gereedschap + Blend Tool Overgangs-gereedschap + Bloat Tool Opblazen-gereedschap + Blob Brush Tool Blob-penseel-gereedschap + Column Graph Tool Kolomdiagram-gereedschap + Crystallize Tool Kristalliseren-gereedschap + Delete Anchor Point Tool Ankerpunt verwijderen + Direct Selection Tool Direct selecteren-gereedschap + Direct Selection Direct selecteren + Ellipse Tool Ellips-gereedschap + Eraser Tool Gum-gereedschap + Eyedropper Tool Pipet-gereedschap + Flare Tool Flits-gereedschap + Free Transform Tool Vrije transformatie-gereedschap + Gradient Tool Verloop-gereedschap + Group Selection Tool Groepsselectie-gereedschap + Group Selection Groepsselectie + Hand Tool Hand-gereedschap + Hand Hand + Join Tool Samenvoegen-gereedschap + Lasso Tool Lasso-gereedschap + Lasso Lasso + Line Cut Tool Lijnknip-gereedschap + Line Graph Tool Lijndiagram-gereedschap + Line Segment Tool Lijnsegment-gereedschap + Live Paint Selection Tool Live-verfselectie-gereedschap + Magic Wand Tool Toverstaf-gereedschap + Measure Tool Meten-gereedschap + Measure Meten + Mesh Tool Vlechtwerk-gereedschap + Mirror & Cut Tool Spiegelen & knippen-gereedschap + Paintbrush Tool Penseel-gereedschap + Path Eraser Tool Padgum-gereedschap + Pencil Tool Potlood-gereedschap + Perspective Grid Tool Perspectiefraster-gereedschap + Perspective Selection Tool Perspectiefselectie-gereedschap + Pie Graph Tool Cirkeldiagram-gereedschap + Polar Grid Tool Polair raster-gereedschap + Polygon Tool Polygoon-gereedschap + Print Tiling Tool Afdruktegeling-gereedschap + Pucker Tool Krimpen-gereedschap + Puppet Warp Tool Marionetvervorming-gereedschap + Radar Graph Tool Radardiagram-gereedschap + Rectangle Cut Tool Rechthoekknip-gereedschap + Rectangle Tool Rechthoek-gereedschap + Rectangular Grid Tool Rechthoekig raster-gereedschap + Reflect Tool Spiegelen-gereedschap + Reshape Tool Hervormen-gereedschap + Rotate Tool Draaien-gereedschap + Rotate View Tool Weergave draaien-gereedschap + Rotate View Weergave draaien + Rounded Rectangle Tool Afgeronde rechthoek-gereedschap + Scale Tool Schalen-gereedschap + Scallop Tool Kartelen-gereedschap + Scatter Graph Tool Spreidingsdiagram-gereedschap + Scissors Tool Schaar-gereedschap + Scissors Schaar + Selection Tool Selectie-gereedschap + Shape Builder Tool Vormopbouw-gereedschap + Shaper Tool Shaper-gereedschap + Shear Tool Schuintrek-gereedschap + Slice Selection Tool Slice-selectie-gereedschap + Slice Tool Slice-gereedschap + Smooth Tool Gladmaken-gereedschap + Spiral Tool Spiraal-gereedschap + Stacked Bar Graph Tool Gestapeld staafdiagram-gereedschap + Stacked Column Graph Tool Gestapeld kolomdiagram-gereedschap + Star Tool Ster-gereedschap + Symbol Screener Tool Symbooltransparantie-gereedschap + Symbol Scruncher Tool Symboolverdichter-gereedschap + Symbol Shifter Tool Symboolverschuiver-gereedschap + Symbol Sizer Tool Symboolgrootte-gereedschap + Symbol Spinner Tool Symboolrotatie-gereedschap + Symbol Sprayer Tool Symboolspuit-gereedschap + Symbol Stainer Tool Symboolkleuring-gereedschap + Symbol Styler Tool Symboolstijl-gereedschap + Touch Type Tool Touch Type-gereedschap + Touch Type Touch Type + Twirl Tool Werveling-gereedschap + Type on a Path Tool Typen op pad + Type Tool Type-gereedschap + Vertical Area Type Tool Verticaal vlaktype-gereedschap + Vertical Area Type Verticaal vlaktype + Vertical Type on a Path Tool Verticaal typen op pad + Vertical Type on a Path Verticaal typen op pad + Vertical Type Tool Verticaal type-gereedschap + Vertical Type Verticaal type + Warp Tool Vervormen-gereedschap + Width Tool Breedte-gereedschap + Wrinkle Tool Rimpelen-gereedschap + Zoom Tool Zoom-gereedschap + Action {n} Actie {n} + Begin Recording Opname starten + Center on Artboard Centreren op tekengebied + Default Actions Standaardacties + ▾ Default Actions ▾ Standaardacties + Duplicate and Offset 10 pt Dupliceren en verschuiven met 10 pt + No actions. Press ● to record. Geen acties. Druk op ● om op te nemen. + Outline Text Tekst omlijnen + Play Current Selection Huidige selectie afspelen + Reflect Horizontal Horizontaal spiegelen + Rotate 90° CW 90° met de klok mee draaien + Set 1 Set 1 + Stop Playing/Recording Afspelen/opnemen stoppen + Unite and Simplify Verenigen en vereenvoudigen + ● Recording ● Opnemen + Align to Artboard Uitlijnen op tekengebied + Align to Glyph Bounds Uitlijnen op glyphbegrenzing + Align to Key Object Uitlijnen op sleutelobject + Align to Selection Uitlijnen op selectie + Align To: Uitlijnen op: + Cancel Key Object Sleutelobject annuleren + Distribute Objects: Objecten verdelen: + Distribute Spacing: Tussenruimte verdelen: + Horizontal Distribute Center Horizontaal midden verdelen + Horizontal Distribute Left Horizontaal links verdelen + Horizontal Distribute Right Horizontaal rechts verdelen + Horizontal Distribute Space Horizontale tussenruimte verdelen + Vertical Distribute Bottom Verticaal onder verdelen + Vertical Distribute Center Verticaal midden verdelen + Vertical Distribute Space Verticale tussenruimte verdelen + Vertical Distribute Top Verticaal boven verdelen + Add New Effect Nieuw effect toevoegen + Click to choose a swatch, Shift-click to mix a colour Klik om een staal te kiezen, Shift-klik om een kleur te mengen + Click to toggle visibility Klik om de zichtbaarheid te wisselen + Dashed Gestreept + Delete Selected Item Geselecteerd item verwijderen + Double-click to edit the characters' paint Dubbelklik om de verf van de tekens te bewerken + Double-click to target the contents Dubbelklik om de inhoud als doel te selecteren + Drag onto art to apply this appearance Sleep naar de illustratie om deze vormgeving toe te passen + Duplicate Selected Item Geselecteerd item dupliceren + Fill: Vulling: + Hide Thumbnail Miniatuur verbergen + Mixed Appearances Gemengde vormgevingen + Mixed Objects Gemengde objecten + No options Geen opties + No Selection Geen selectie + Offset X Verschuiving X + Opacity: 50% Multiply Dekking: 50% Vermenigvuldigen + Opacity: Default Dekking: Standaard + Opacity: {value} Dekking: {value} + Rectangle: Sunshine Rechthoek: Sunshine + Redefine Graphic Style “{name}” Grafische stijl „{name}” opnieuw definiëren + Show Thumbnail Miniatuur tonen + Artboard Options: edit with the Artboard tool Opties voor tekengebied: bewerken met het Tekengebied-gereedschap + Delete Artboards Tekengebieden verwijderen + Delete Empty Artboards Lege tekengebieden verwijderen + Duplicate Artboards Tekengebieden dupliceren + Move Down Omlaag verplaatsen + Move Up Omhoog verplaatsen + Open a document to see its artboards. Open een document om de tekengebieden te zien. + Rearrange All Artboards Alle tekengebieden herschikken + Move Artwork with Artboard Illustratie met tekengebied meeverplaatsen + Scale Artwork with Artboard Illustratie met tekengebied meeschalen + Add Selected Artwork (Alt: as one asset) Geselecteerde illustratie toevoegen (Alt: als één asset) + Add Selected Artwork as Multiple Assets Geselecteerde illustratie als meerdere assets toevoegen + Double-click to rename Dubbelklik om te hernoemen + Drag art here, or select it and click +. Hold Alt for one asset of several objects. Sleep illustraties hierheen, of selecteer ze en klik op +. Houd Alt ingedrukt voor één asset van meerdere objecten. + Export the selected assets (else all of them) De geselecteerde assets exporteren (anders alle) + Export {count} Selected {count} geselecteerde exporteren + Select All Assets Alle assets selecteren + Don't Show Center Midden niet tonen + Hide Note Notitie verbergen + Image Map: Afbeeldingskaart: + Note: Notitie: + Open the URL in the web browser De URL openen in de webbrowser + Overprint Fill Vulling overdrukken + Overprint Stroke Lijn overdrukken + Recent URLs Recente URL's + Reverse Path Direction Off Padrichting omkeren uit + Reverse Path Direction On Padrichting omkeren aan + Show Center Midden tonen + Show Note Notitie tonen + URL: URL: + Use Even-Odd Fill Rule Even-odd-vulregel gebruiken + Use Non-Zero Winding Fill Rule Non-zero-winding-vulregel gebruiken + Brush Libraries (on the roadmap) Penseelbibliotheken (op de roadmap) + List View Lijstweergave + New Art Brush from Selection Nieuw kunstpenseel uit selectie + New Bristle Brush Nieuw borstelpenseel + New Calligraphic Brush Nieuw kalligrafisch penseel + New Pattern Brush from Selection Nieuw patroonpenseel uit selectie + New Scatter Brush from Selection Nieuw verspreidingspenseel uit selectie + No brushes Geen penselen + Select art and use New Brush to make one. Selecteer illustratie en gebruik Nieuw penseel om er een te maken. + Show {kind} Brushes {kind} penselen tonen + Thumbnail View Miniatuurweergave + All Caps Alle kapitalen + Character Alignment Tekenuitlijning + Character Rotation Tekenrotatie + Em Box Bottom/Left Em-vak onder/links + Em Box Center Em-vak midden + Em Box Top/Right Em-vak boven/rechts + Fractional Widths Fractionele breedtes + Hide Options Opties verbergen + Horizontal Scale % Horizontale schaal % + Kerning (0 = Auto) Kerning (0 = Auto) + Leading Regelafstand + No Break Niet afbreken + Proportional Metrics Proportionele metingen + Digits Cijfers + Arabic Arabisch + Hindi Hindi + Farsi Farsi + Reset Panel Paneel herstellen + Roman Baseline Romeinse basislijn + Select a text object to edit its character attributes. Selecteer een tekstobject om de tekenkenmerken ervan te bewerken. + Show Options Opties tonen + Small Caps Kleine kapitalen + Small Caps (on the roadmap) Kleine kapitalen (op de roadmap) + Standard Vertical Roman Alignment Standaard verticale Romeinse uitlijning + Strikethrough Doorhalen + Character: Teken: + Character options Tekenopties + Subscript Subscript + Subscript (on the roadmap) Subscript (op de roadmap) + Superscript Superscript + Superscript (on the roadmap) Superscript (op de roadmap) + System Layout Systeemlayout + Tate-chu-yoko Tate-chu-yoko + Underline Onderstrepen + Vertical Scale % Verticale schaal % + Axis As + ICF Top/Right ICF boven/rechts + ICF Bottom/Left ICF onder/links + Copy Color Value (Hex) Kleurwaarde kopiëren (Hex) + Create New Swatch… Nieuw staal maken… + Name 40% Naam 40% + Out of Gamut Warning Waarschuwing buiten gamut + Out of Web Color Warning Waarschuwing buiten webkleur + Web Safe RGB Websafe RGB + {warning}: click to correct to the closest color ({hex}) {warning}: klik om te corrigeren naar de dichtstbijzijnde kleur ({hex}) + Edit or Apply Colors Kleuren bewerken of toepassen + Limit colors to swatch library Kleuren beperken tot staalbibliotheek + Limit colors to swatch library: {name} Kleuren beperken tot staalbibliotheek: {name} + Save color group to Swatch panel Kleurgroep opslaan in het Stalen-paneel + Save Colors as Swatches Kleuren als stalen opslaan + Save selected colors as swatches Geselecteerde kleuren als stalen opslaan + Set base color Basiskleur instellen + Set base color to the current color Basiskleur op de huidige kleur instellen + Show Tints/Shades Tinten/schakeringen tonen + Show Vivid/Muted Levendig/gedempt tonen + Show Warm/Cool Warm/koel tonen + Click to select, double-click to edit Klik om te selecteren, dubbelklik om te bewerken + Color Theme Kleurthema + Delete theme Thema verwijderen + Edit Theme Thema bewerken + Edit theme Thema bewerken + Make a theme on Create and save it. Maak een thema op Create en sla het op. + My Themes Mijn thema's + New theme from the current color Nieuw thema uit de huidige kleur + No saved themes Geen opgeslagen thema's + Rule: Regel: + Save changes to {name} Wijzigingen opslaan in {name} + Save theme to My Themes Thema opslaan in Mijn thema's + Set the selected color to the current color De geselecteerde kleur op de huidige kleur instellen + Copied the CSS of {rules} object(s) CSS van {rules} object(en) gekopieerd + Copy All Styles Alle stijlen kopiëren + Exported {path} {path} geëxporteerd + Generate CSS: the whole document's CSS while nothing is selected CSS genereren: de CSS van het hele document wanneer er niets is geselecteerd + No CSS Geen CSS + Select objects to see their CSS, or Generate CSS for the whole document. Selecteer objecten om hun CSS te zien, of Genereer CSS voor het hele document. + The selected objects have no CSS of their own (see below). De geselecteerde objecten hebben geen eigen CSS (zie hieronder). + Units: {units} Eenheden: {units} + {count} more rules: Export writes every one. {count} regels meer: Export schrijft ze allemaal. + {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} naamloze objecten weggelaten: geef ze een naam, of zet Naamloze objecten aan in het paneelmenu. + All Categories Alle categorieën + Save… Opslaan… + Selection Only Alleen selectie + {title} (selection) {title} (selectie) + Changed since the last Refresh. Gewijzigd sinds de laatste vernieuwing. + Click to zoom in, Alt-click to zoom out, drag to pan, double-click to fit Klik om in te zoomen, Alt-klik om uit te zoomen, sleep om te verschuiven, dubbelklik om passend te maken + Fit in Window Passend in venster + Open a document to preview its flattening. Open een document om de afvlakking te bekijken. + Overprints: Overdrukken: + Preview the document as it is now Bekijk een voorbeeld van het document zoals het nu is + Raster/Vector Balance: Raster/vectorbalans: + Refresh Vernieuwen + Save Transparency Flattener Preset… Voorinstelling voor transparantieafvlakking opslaan… + Basic Latin Basic Latin + Currency & Symbols Valuta & symbolen + Double-click to insert Dubbelklik om in te voegen + Entire Font Volledig lettertype + glyph {id} glyph {id} + Greek & Cyrillic Grieks & Cyrillisch + Latin Extended Latin Extended + Latin-1 Supplement Latin-1 Supplement + No fonts are available. Er zijn geen lettertypen beschikbaar. + Select text or click into text with the Type tool to insert glyphs Selecteer tekst of klik in de tekst met het Type-gereedschap om glyphs in te voegen + Show: Weergeven: + Use the Selected Text's Font Lettertype van geselecteerde tekst gebruiken + Aspect Ratio Beeldverhouding + Click the art with the Gradient tool to add or select points. Klik met het Verloop-gereedschap op de illustratie om punten toe te voegen of te selecteren. + Click the ramp or a type button to apply a gradient. Klik op de verloopbalk of op een typeknop om een verloop toe te passen. + Color: Kleur: + Delete Stop Stoppunt verwijderen + Edit Gradient Verloop bewerken + Edit the selected point's colour in the Color panel Bewerk de kleur van het geselecteerde punt in het Kleur-paneel + Edit the stop Stoppunt bewerken + Eyedropper: click the art to sample the stop's colour Pipet: klik op de illustratie om de kleur van het stoppunt over te nemen + Gradient across stroke Verloop over de lijn + Gradient along stroke Verloop langs de lijn + Gradient Fill: click to apply, drag onto art Verloopvulling: klik om toe te passen, sleep naar de illustratie + Gradient swatches Verloopstalen + Gradient within stroke Verloop binnen de lijn + Linear Gradient Lineair verloop + Lines: clicks add points joined by a line Lijnen: klikken voegen punten toe die door een lijn worden verbonden + No gradient swatches Geen verloopstalen + Points: clicks add free points Punten: klikken voegen vrije punten toe + Radial Gradient Radiaal verloop + Reset to White, Black Herstellen naar wit, zwart + Reverse Gradient Verloop omkeren + Save to Swatches Opslaan in stalen + Delete Graphic Style (Alt-click: without asking) Grafische stijl verwijderen (Alt-klik: zonder vragen) + Delete the graphic style “{name}”? De grafische stijl „{name}” verwijderen? + Delete these {n} graphic styles? Deze {n} grafische stijlen verwijderen? + Delete this {n} graphic style? Deze {n} grafische stijl verwijderen? + Graphic Style Libraries Menu Menu grafische stijlbibliotheken + New Graphic Style (Alt-click to name it) Nieuwe grafische stijl (Alt-klik om een naam te geven) + New Graphic Style… Nieuwe grafische stijl… + No graphic styles Geen grafische stijlen + Objects using them keep their look but are no longer linked. Objecten die ze gebruiken behouden hun uiterlijk, maar zijn niet meer gekoppeld. + Open a document to see its graphic styles. Open een document om de grafische stijlen te zien. + Open Graphic Style Library Grafische stijlbibliotheek openen + Select styled art and click New Graphic Style. Selecteer illustratie met een stijl en klik op Nieuwe grafische stijl. + Use Square for Previews Vierkant gebruiken voor voorbeelden + Use Text for Previews Tekst gebruiken voor voorbeelden + {percent}% opacity {percent}% dekking + History Options… Historieopties… + Open a document to see its history. Open een document om de historie te zien. + Abutting Aaneengesloten + Black and White Zwart-wit + Corners Hoeken + Auto Grouping Automatisch groeperen + Enhanced Verbeterd + Ignore Color Kleur negeren + Ignore White Wit negeren + Palette: Palet: + Limited Beperkt + Full Tone Volledige tint + Document Library Documentbibliotheek + Library: Bibliotheek: + Tracing Presets Traceervoorinstellingen + Method: Methode: + Mode: Modus: + Noise Ruis + Create: Maken: + Fills Vullingen + Strokes Lijnen + Overlapping Overlappend + Paths: {paths} Anchors: {anchors} Colors: {colors} Paden: {paths} Ankerpunten: {anchors} Kleuren: {colors} + Reset to Default Herstellen naar standaard + Select an image to trace Selecteer een afbeelding om te traceren + Snap Curves To Lines Curves vastzetten op lijnen + Tracing Result with Outlines Traceringsresultaat met omlijningen + Source Image Bronafbeelding + Threshold Drempel + Trace Traceren + Trace the image with the Default preset De afbeelding traceren met de standaardvoorinstelling + Size: {size} Tracking: {tracking} Grootte: {size} Spatiëring: {tracking} + + Create New Layer Nieuwe laag maken + Create New Sublayer Nieuwe sublaag maken + Search All Alles doorzoeken + Graphics Illustraties + Local Libraries Lokale bibliotheken + Add Content Inhoud toevoegen + Click to apply to the selected type Klik om toe te passen op de geselecteerde tekst + Create New Library… Nieuwe bibliotheek maken… + Rename Library… Bibliotheek hernoemen… + Delete the library “{name}”? De bibliotheek „{name}” verwijderen? + Drag art or swatches here, or use + to add the selection's colors and text styles. Sleep illustraties of stalen hierheen, of gebruik + om de kleuren en tekststijlen van de selectie toe te voegen. + Its graphics, colors and text styles are deleted from this machine. De illustraties, kleuren en tekststijlen worden van deze computer verwijderd. + Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. Bewaar illustraties, kleuren en tekststijlen hier om ze in elk document te gebruiken. Bibliotheken worden op deze computer opgeslagen — geen account vereist. + This library is empty Deze bibliotheek is leeg + No results Geen resultaten + No items in this library match “{query}”. Geen items in deze bibliotheek komen overeen met „{query}”. + Right-click an item to move it into this group. Klik met de rechtermuisknop op een item om het naar deze groep te verplaatsen. + New Group Nieuwe groep + New Group… Nieuwe groep… + Move to Group Verplaatsen naar groep + No Group Geen groep + Rename Group… Groep hernoemen… + Delete Group Groep verwijderen + Import Library… Bibliotheek importeren… + Export Library… Bibliotheek exporteren… + Close Library Bibliotheek sluiten + Next Library Volgende bibliotheek + No matches. Geen overeenkomsten. + Other Libraries Andere bibliotheken + Previous Library Vorige bibliotheek + Swatch exchange files keep solid colors only. Uitwisselingsbestanden voor stalen bevatten alleen effen kleuren. + This library is empty. Deze bibliotheek is leeg. + Color Space Kleurruimte + Created Gemaakt + Dimensions Afmetingen + Embedded {n} image(s) {n} afbeelding(en) ingesloten + Embedded {n} image(s); {missing} missing: relink them first {n} afbeelding(en) ingesloten; {missing} ontbreken: koppel ze eerst opnieuw + File › Place… brings in an image, linked or embedded. Bestand › Plaatsen… haalt een afbeelding binnen, gekoppeld of ingesloten. + Format Formaat + Medium Thumbnails Middelgrote miniaturen + Missing: {path} Ontbreekt: {path} + Modified Gewijzigd + Modified since it was read: {path} Gewijzigd sinds het is ingelezen: {path} + No Thumbnails Geen miniaturen + Nothing matches the Show filter. Niets komt overeen met het weergavefilter. + PPI PPI + Relinked {n} image(s) {n} afbeelding(en) opnieuw gekoppeld + Relinked {n} image(s); not in that folder: {} {n} afbeelding(en) opnieuw gekoppeld; niet in die map: {} + Relinked {} image(s) {} afbeelding(en) opnieuw gekoppeld + Relink… Opnieuw koppelen… + Find Linked Files Gekoppelde bestanden zoeken + Find Linked Files in Folder… Gekoppelde bestanden in map zoeken… + Choose the file to relink each linked file to. Files with the size and contents saved with the link come first. Kies het bestand waarmee elk gekoppeld bestand opnieuw gekoppeld moet worden. Bestanden met de grootte en inhoud die bij de koppeling zijn opgeslagen, komen eerst. + Same file Zelfde bestand + Same size Zelfde grootte + Don't Relink Niet opnieuw koppelen + Copy into the document's folder Kopiëren naar de map van het document + Select an image to see its Link Info. Selecteer een afbeelding om de koppelingsinformatie te zien. + Show Embedded Ingesloten tonen + Show Link Info Koppelingsinformatie tonen + Show Modified Gewijzigde tonen + Small Thumbnails Kleine miniaturen + Status Status + Unembedded to {} Niet meer ingesloten: {} + Update Link Koppeling bijwerken + Updated {} image(s) {} afbeelding(en) bijgewerkt + Break Link Koppeling verbreken + Tolerance: Tolerantie: + Transparency Options Transparantieopties + Color Mixer (Shift-click the chip) Kleurenmixer (Shift-klik op de chip) + Fill: click for swatches, Shift-click for the color mixer Vulling: klik voor stalen, Shift-klik voor de kleurenmixer + Stroke: click for swatches, Shift-click for the color mixer Lijn: klik voor stalen, Shift-klik voor de kleurenmixer + This panel is on the roadmap (see the parity plan). Dit paneel staat op de roadmap (zie het pariteitsplan). + Open a document to navigate it. Open een document om erin te navigeren. + View Artboard Only Alleen tekengebied weergeven + Contextual Alternates Contextuele alternatieven + Default Figure Standaardcijfer + Discretionary Ligatures Optionele ligaturen + Figure: Cijfer: + Fractions Breuken + Ligatures are off while the text is tracked. Ligaturen staan uit terwijl de tekst wordt gespatieerd. + Ordinals Rangtelwoorden + Proportional Oldstyle Proportionele oldstyle + Reset OpenType Features OpenType-functies herstellen + Select text to set its OpenType features. Selecteer tekst om de OpenType-functies in te stellen. + Standard Ligatures Standaardligaturen + Swash Swash + Tabular Lining Tabulaire lining + Tabular Oldstyle Tabulaire oldstyle + Every-line Composer Compositie per alinea + First-line Left Indent Eerste regel links inspringen + Hyphenation… Afbreken… + Justification… Uitvullen… + Justify with last line aligned center Uitvullen met laatste regel gecentreerd + Justify with last line aligned right Uitvullen met laatste regel rechts uitgelijnd + Left Indent Links inspringen + Left-to-Right Paragraph Direction Alinearichting van links naar rechts + Line-end Punctuation Half Width Leestekens op regeleinde op halve breedte + Mojikumi Set Mojikumi-set + Kinsoku Set Kinsoku-set + Hard Hard + Right Indent Rechts inspringen + Right-to-Left Paragraph Direction Alinearichting van rechts naar links + Roman Hanging Punctuation Romeinse hangende leestekens + Select a text object to edit its paragraph attributes. Selecteer een tekstobject om de alineakenmerken ervan te bewerken. + Single-line Composer Compositie per regel + Space After Paragraph Ruimte na alinea + Space Before Paragraph Ruimte voor alinea + Top-to-Top Leading Regelafstand van boven naar boven + Bottom-to-Bottom Leading Regelafstand van onder naar onder + Burasagari Burasagari + Force Forceren + Expand applies to compound shapes (Alt-click a shape mode) — on the roadmap Uitvouwen is van toepassing op samengestelde vormen (Alt-klik op een vormmodus) — staat op de roadmap + Expand Compound Shape Samengestelde vorm uitvouwen + Make Compound Shape Samengestelde vorm maken + Pathfinder Options… Pathfinder-opties… + Pathfinders: Pathfinders: + Release Compound Shape Samengestelde vorm vrijgeven + Repeat Pathfinder Pathfinder herhalen + Repeat {name} {name} herhalen + Trap… Overbrugging… + Bottom in Front Onderkant vooraan + Brick by Column Baksteen per kolom + Brick by Row Baksteen per rij + Brick Offset: Baksteenverschuiving: + Copies: Kopieën: + Dim Copies to: Kopieën dimmen tot: + H Spacing: H-afstand: + Height: Hoogte: + Hex by Column Zeshoek per kolom + Hex by Row Zeshoek per rij + Left in Front Linkerkant vooraan + No document Geen document + Open a document to edit its patterns. Open een document om de patronen te bewerken. + Overlap: Overlap: + Pattern options are available in pattern editing mode. Patroonopties zijn beschikbaar in de patroonbewerkingsmodus. + Right in Front Rechterkant vooraan + Select art and choose Object › Pattern › Make. Selecteer artwork en kies Object › Patroon › Maken. + Show Swatch Bounds Staalgrenzen weergeven + Show Tile Edge Tegelrand weergeven + Size Tile to Art Tegel aan artwork aanpassen + Tile Type: Tegeltype: + Top in Front Bovenkant voorop + V Spacing: V-spatiëring: + Width: Breedte: + Appearance panel paneel Uiterlijk + Arrange: Bring to Front Rangschikken: Naar voren brengen + Draw: Tekenen: + Edit Artboards Tekenborden bewerken + Flip Along Horizontal Axis Om de horizontale as spiegelen + Flip Along Vertical Axis Om de verticale as spiegelen + Hyphenate Afbreken + Isolate Group Groep isoleren + Landscape Liggend + More Pathfinder options Meer Pathfinder-opties + More Align options Meer uitlijnopties + More Paragraph options Meer alineaopties + No document open Geen document geopend + Quick Actions Snelle acties + Rearrange All Alles herschikken + Rulers & Grids Linialen & rasters + Select a text object Selecteer een tekstobject + Show Grid (⌘') Raster weergeven (⌘') + Show Rulers (⌘R) Linialen weergeven (⌘R) + Smart Guides (⌘U) Slimme hulplijnen (⌘U) + Snap Options Snapopties + Type: Type: + All colours are within the CMYK gamut Alle kleuren vallen binnen het CMYK-gamut + Assign: Toewijzen: + Color Settings Kleurinstellingen + Gamut Check Gamutcontrole + Intent: Intentie: + No document open. Geen document geopend. + Overprint Preview (separations) Overprintvoorbeeld (separaties) + Show All Plates Alle platen weergeven + Show all plates Alle platen weergeven + Show/hide plate (Alt-click: only this plate) Plaat weergeven/verbergen (Alt-klik: alleen deze plaat) + Simulate Paper Papier simuleren + Use Black Point Compensation Zwartpuntcompensatie gebruiken + Working CMYK (don't tag) Werk-CMYK (niet taggen) + {name} (spot) {name} (spotkleur) + Add to Profiles… Toevoegen aan profielen… + Align Stroke to Center Lijn in het midden uitlijnen + Align Stroke to Inside Lijn aan de binnenkant uitlijnen + Align Stroke to Outside Lijn aan de buitenkant uitlijnen + Align Stroke: Lijn uitlijnen: + Align: Uitlijnen: + Arrowheads: Pijlpunten: + Bevel Join Schuine aansluiting + Butt Cap Stomp einde + Cap: Lijneinde: + Corner: Hoek: + End arrowhead Eindpijlpunt + Exact dash lengths Exacte streepjeslengtes + Fit dashes to corners and ends Streepjes uitlijnen op hoeken en uiteinden + Flip Across Overdwars spiegelen + Flip Along In lengterichting spiegelen + gap tussenruimte + Limit: Limiet: + Link start and end arrowhead scales Schaal van begin- en eindpijlpunt koppelen + Miter Join Hoekaansluiting + Profile: Profiel: + Projecting Cap Uitstekend einde + Round Cap Rond einde + Round Join Ronde aansluiting + Scale: Schaal: + Start arrowhead Beginpijlpunt + Stroke options Lijnopties + Swap start and end arrowheads Begin- en eindpijlpunt omwisselen + Tip extends past the end point Punt steekt uit voorbij het eindpunt + Tip on the end point Punt op het eindpunt + Variable Width Profile Variabel breedteprofiel + Weight: Dikte: + Art using a deleted global swatch keeps its colour. Artwork met een verwijderd globaal staal behoudt zijn kleur. + Color Group: {name} Kleurgroep: {name} + Delete these {count} swatches and groups? Deze {count} stalen en groepen verwijderen? + Delete “{name}”? „{name}” verwijderen? + Document Swatches Documentstalen + Edit or Apply Color Group Kleurgroep bewerken of toepassen + Edit or Apply Color Group… Kleurgroep bewerken of toepassen… + Global Process Color Globale proceskleur + Global Process Color, CMYK Globale proceskleur, CMYK + Large List View Grote lijstweergave + Large Thumbnail View Grote miniatuurweergave + Medium Thumbnail View Middelgrote miniatuurweergave + Open a document to see its swatches. Open een document om de stalen te zien. + Open Swatch Library Staalbibliotheek openen + Process Color Proceskleur + Show All Swatches Alle stalen weergeven + Show Color Groups Kleurgroepen weergeven + Show Color Swatches Kleurstalen weergeven + Show Find Field Zoekveld weergeven + Show Gradient Swatches Verloopstalen weergeven + Show List View Lijstweergave tonen + Show Pattern Swatches Patroonstalen weergeven + Pattern Swatches Patroonstalen + Show Swatch Kinds Soorten stalen weergeven + Show Thumbnail View Miniatuurweergave tonen + Small List View Kleine lijstweergave + Small Thumbnail View Kleine miniatuurweergave + Swatch Libraries Menu Menu Staalbibliotheken + User Library Gebruikersbibliotheek + {kind} Gradient {kind} verloop + {kind}, {model} {kind}, {model} + No symbols in this document Geen symbolen in dit document + Select art and click New Symbol to make one. Selecteer artwork en klik op Nieuw symbool om er een te maken. + Symbol Libraries (on the roadmap) Symboolbibliotheken (op de roadmap) + Align On: Uitlijnen op: + Center-Justified Tab Midden uitgelijnde tabstop + Above Text Boven tekst + Position Panel Above Text Paneel boven tekst plaatsen + Click to add a tab stop, drag to move it, drag it off the ruler to delete it Klik om een tabstop toe te voegen, sleep om hem te verplaatsen, sleep hem van de liniaal om hem te verwijderen + Decimal-Justified Tab Decimaal uitgelijnde tabstop + Delete Tab Tabstop verwijderen + Leader: Opvulteken: + Left-Justified Tab Links uitgelijnde tabstop + No text selected Geen tekst geselecteerd + Repeat Tab Tabstop herhalen + Right-Justified Tab Rechts uitgelijnde tabstop + Select a text object to set its tab stops. Selecteer een tekstobject om de tabstops in te stellen. + Select text Selecteer tekst + + Add Scale + Schaal toevoegen + Add Selected Artwork as One Asset Geselecteerde artwork als één asset toevoegen + Export Settings Exportinstellingen + No assets Geen assets + Remove Selected Assets Geselecteerde assets verwijderen + Suffix Achtervoegsel + Absolute Position Absolute positie + Export All… Alles exporteren… + Export Selected CSS… Geselecteerde CSS exporteren… + No selection Geen selectie + Rasterize Unsupported Art Niet-ondersteunde artwork rasteriseren + Unnamed Objects Naamloze objecten + Width and Height Breedte en hoogte + Add New Nieuw toevoegen + Delete Point Punt verwijderen + Location: Locatie: + Spread: Spreiding: + Clip to Bounding Box Bijsnijden tot begrenzingskader + Effective PPI Effectieve PPI + Embed Image(s) Afbeelding(en) insluiten + Embedded Image Ingesloten afbeelding + Large Thumbnails Grote miniaturen + Linked Gekoppeld + Location Locatie + No images Geen afbeeldingen + Relink to Folder… Opnieuw koppelen aan map… + Show Missing Ontbrekende weergeven + Sort by Status Sorteren op status + Clear Overrides Onderdrukkingen wissen + Create New {kind} Style Nieuwe {kind}stijl maken + Delete {kind} Style {kind}stijl verwijderen + Duplicate {kind} Style {kind}stijl dupliceren + New {kind} Style… Nieuwe {kind}stijl… + Redefine {kind} Style {kind}stijl opnieuw definiëren + {kind} Style Options… Opties voor {kind}stijl… + Angle: Hoek: + Ellipse Properties: Ellips-eigenschappen: + Invert Pie Taart omkeren + Side Length: Zijlengte: + Make Sides Equal Zijden gelijk maken + Pie End Angle: Eindhoek taart: + Pie Start Angle: Beginhoek taart: + Corner Radius: Hoekradius: + Radius: Radius: + Round Afgerond + Flip Horizontal Horizontaal spiegelen + Flip Vertical Verticaal spiegelen + Polygon Properties: Veelhoekeigenschappen: + Rectangle Properties: Rechthoekeigenschappen: + Shape Properties: Vormeigenschappen: + Sides: Zijden: + Transform options Transformatieopties + Transform Both Beide transformeren + Transform Object Only Alleen object transformeren + Transform Pattern Only Alleen patroon transformeren + Use Registration Point for Symbol Registratiepunt voor symbool gebruiken + Click to edit the mask; Alt-click to view only the mask; Shift-click to disable or enable it Klik om het masker te bewerken; Alt-klik om alleen het masker te bekijken; Shift-klik om het in of uit te schakelen + Hide Thumbnails Miniaturen verbergen + Invert Mask Masker omkeren + Isolate Blending Vermenging isoleren + Knockout Group Knockoutgroep + Link the mask Het masker koppelen + Make Mask Masker maken + Opacity & Mask Define Knockout Shape Dekking & masker bepalen de knockoutvorm + Select the art (and, on top of it, the mask object) Selecteer de artwork (en, daar bovenop, het maskerobject) + Show Thumbnails Miniaturen weergeven + Stop editing the opacity mask Stop met het bewerken van het dekkingmasker + Unlink the mask Het masker ontkoppelen + Data Sets Gegevenssets + Data Set Gegevensset + Select Bound Object Gekoppeld object selecteren + Select All Bound Objects Alle gekoppelde objecten selecteren + Multiple objects Meerdere objecten + Graphics: {adapter} Grafisch: {adapter} + Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft. Onderdeel van ArtCraft. MIT OR Apache-2.0. Lettertypen: Source Sans 3, Inter, JetBrains Mono (OFL). Pictogrammen: Lucide (ISC) + VectorCraft. + Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono, Noto Sans SC (OFL). Icons: Lucide (ISC) + VectorCraft. Onderdeel van ArtCraft. MIT OR Apache-2.0. Lettertypen: Source Sans 3, Inter, JetBrains Mono, Noto Sans SC (OFL). Pictogrammen: Lucide (ISC) + VectorCraft. + Version {version} — open-source vector illustration in pure Rust. Versie {version} — open-source vectorillustratie in pure Rust. + Align to Page Uitlijnen op pagina + Smooth Color Vloeiende kleur + Specified Distance Opgegeven afstand + Specified Steps Opgegeven stappen + Direction: Richting: + Scatter Brush Options Opties voor verspreidingspenseel + Art Brush Options Opties voor artpenseel + Pattern Brush Options Opties voor patroonpenseel + Scatter: Verspreiding: + Rotation: Rotatie: + Rotation relative to: Rotatie ten opzichte van: + Brush Scale Options Opties voor penseelschaling + Scale Proportionately Proportioneel schalen + Stretch to Fit Stroke Length Uitrekken tot lijnlengte + Stretch Between Guides Uitrekken tussen hulplijnen + Start: Begin: + End: Einde: + Left to Right Links naar rechts + Right to Left Rechts naar links + Top to Bottom Boven naar beneden + Bottom to Top Onder naar boven + Outer Corner Tile Tegel voor buitenhoek + Side Tile Zijtegel + Inner Corner Tile Tegel voor binnenhoek + Start Tile Begintegel + End Tile Eindtegel + Fit Passen + Stretch to fit Uitrekken om te passen + Add space to fit Ruimte toevoegen om te passen + Approximate path Pad benaderen + Colorization Inkleuring + Tints Tinten + Tints and Shades Tinten en schakeringen + Hue Shift Tintverschuiving + Key Color: Sleutelkleur: + Size: Grootte: + Calligraphic Brush Options Opties voor kalligrafisch penseel + Bristle Brush Options Opties voor borstelpenseel + Roundness: Rondheid: + Shape: Vorm: + Bristle Length: Borstellengte: + Bristle Density: Borsteldichtheid: + Bristle Thickness: Borsteldikte: + Paint Opacity: Verfdekking: + Stiffness: Stijfheid: + Round Point Ronde punt + Flat Point Platte punt + Round Blunt Ronde stomp + Flat Blunt Platte stomp + Round Curve Ronde curve + Flat Curve Platte curve + Round Angle Ronde hoek + Flat Angle Platte hoek + Round Fan Ronde waaier + Flat Fan Platte waaier + Adjust Options: Aanpassingsopties: + Color Mode: Kleurmodus: + Convert Converteren + Global Globaal + Green Groen + Shifts the tints of global and spot colors; other colors stay. Verschuift de tinten van globale kleuren en spotkleuren; andere kleuren blijven ongewijzigd. + Tint Tint + Color Guide Options Opties voor kleurengids + Less Minder + Steps: Stappen: + Variation: Variatie: + Variation Variatie + Color Models Kleurmodellen + Color Picker Kleurkiezer + Color Swatches Kleurstalen + Only Web Colors Alleen webkleuren + Original: click to restore Origineel: klik om te herstellen + Ascender Stijger + Descender Daler + On Left Side Aan de linkerkant + On Right Side Aan de rechterkant + On Both Sides Aan beide zijden + Short Kort + Full Width Volledige breedte + Chamfer Afkanting + Inverted Round Omgekeerd rond + Bleed: Afrulling: + Discard White Overprint in Output Witte overprint negeren bij uitvoer + Double Quotes: Dubbele aanhalingstekens: + Export and Clipboard Transparency Flattener Settings Instellingen voor de transparantiepletter bij exporteren en klembord + Export: Exporteren: + Grid Colors: Rasterkleuren: + Grid Size: Rastergrootte: + Highlight Substituted Fonts Vervangen lettertypen markeren + Highlight Substituted Glyphs Vervangen glyphs markeren + Language: Taal: + Position Positie + Preserve Text Appearance Uiterlijk van tekst behouden + Preserve Text Editability Tekst bewerkbaar houden + Show Images in Outline Mode Afbeeldingen weergeven in omlijningsmodus + Simulate Colored Paper Gekleurd papier simuleren + Single Quotes: Enkele aanhalingstekens: + Small Caps: Kleine kapitalen: + Subscript: Subscript: + Superscript: Superscript: + Transparency Grid Transparantieraster + Units: Eenheden: + Use Typographer's Quotes Typografische aanhalingstekens gebruiken + Center Artwork Artwork centreren + DXF Import Options DXF-importopties + Layout: Indeling: + Merge Layers Lagen samenvoegen + Place DXF DXF plaatsen + Scale Lineweights Lijndiktes schalen + Scale to Fit Artboard Schalen zodat het op het tekenbord past + Scaling Schaling + Alter Paths for Appearance Paden aanpassen voor uiterlijk + DXF Options DXF-opties + Export Option Exportoptie + Export Selected Art Only Alleen geselecteerde artwork exporteren + Number of Colors: Aantal kleuren: + Raster File Format: Rasterbestandsindeling: + True colour needs version 2004 or later: the nearest of 256 colours is written. Echte kleur vereist versie 2004 of later: de dichtstbijzijnde van 256 kleuren wordt weggeschreven. + Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Tekst wordt omlijningen, en lijnen die een CAD-lijn niet kan tekenen, worden gevulde vormen. + Type stays text, and every stroke is a line with its lineweight and dashes. Tekst blijft tekst en elke lijn wordt een lijn met zijn eigen lijndikte en streepjes. + Version: Versie: + Spin Draaiing + Good Goed + Best Beste + Amount Hoeveelheid + Contrast Contrast + Edit the applied effect, or add another one? Het toegepaste effect bewerken, of nog een toevoegen? + Input White Invoer wit + Output Black Uitvoer zwart + Preserve Lightness Helderheid behouden + Ridges per segment: Ribbels per segment: + Temperature Temperatuur + {effect} is already applied {effect} is al toegepast + Odd Fields Oneven velden + Even Fields Even velden + Duplication Verdubbeling + Interpolation Interpolatie + Anti-Alias Anti-alias + Bend: Buiging: + Distort Appearance Uiterlijk vervormen + Distort Linear Gradients Lineaire verlopen vervormen + Distort Pattern Fills Patroonvullingen vervormen + Distortion Vervorming + Envelope Mesh Omhullingsraster + Maintain Envelope Shape Omhullingsvorm behouden + Preserve Shape Using: Vorm behouden met: + Reset Envelope Mesh Omhullingsraster herstellen + Reset Envelope Shape Omhullingsvorm herstellen + V: V: + Warp Options Warpopties + Compatible Gradient and Gradient Mesh Printing Compatibel afdrukken van verlopen en verlooprasters + Embed Fonts (for other applications) Lettertypen insluiten (voor andere programma's) + EPS Options EPS-opties + Include CMYK PostScript in RGB Files CMYK PostScript opnemen in RGB-bestanden + Include Document Thumbnails Documentminiaturen opnemen + Include Linked Files Gekoppelde bestanden opnemen + Opaque Ondoorzichtig + PostScript: PostScript: + Transparency is flattened with the preset. Type is written as outlines; gradients as bands of colour. Transparantie wordt geplet met de voorinstelling. Tekst wordt als omlijningen weggeschreven; verlopen als kleurbanden. + Transparency is flattened with the preset. Type is written as outlines; gradients stay smooth. Transparantie wordt geplet met de voorinstelling. Tekst wordt als omlijningen weggeschreven; verlopen blijven vloeiend. + Expand Gradient To Verloop uitbreiden tot + Gradient Mesh Verloopraster + Specify: Opgeven: + Export As Exporteren als + One file per artboard: -.{ext} Eén bestand per tekenbord: -.{ext} + One page per artboard. Eén pagina per tekenbord. + The bounds of the visible art. De grenzen van de zichtbare artwork. + Use Artboards Tekenborden gebruiken + Choose a folder Kies een map + Create Sub-folders Submappen maken + Export Asset Asset exporteren + Export to: Exporteren naar: + Format Settings Indelingsinstellingen + Formats: Indelingen: + Full Document Volledig document + Include Bleed Afrulling opnemen + No assets yet. Collect art with Object › Collect for Export, or drag it into the Asset Export panel. Nog geen assets. Verzamel artwork via Object › Verzamelen voor exporteren, of sleep het naar het paneel Asset-export. + Open Location after Export Locatie openen na exporteren + PDF Preset: PDF-voorinstelling: + Prefix: Voorvoegsel: + Remove Verwijderen + Select: Selecteren: + Your browser downloads the files (several as one .zip). Je browser downloadt de bestanden (meerdere als één .zip). + Cap Lijneinde + Dash Pattern Streepjespatroon + Eyedropper Applies: Pipet past toe: + Eyedropper Picks Up: Pipet neemt op: + Focal Fill Focale vulling + Focal Stroke Focale lijn + Miter Limit Hoeklimiet + Point Sample Puntsample + Raster Sample Size: Rastersamplegrootte: + Weight Dikte + 3 x 3 Average 3 x 3 gemiddeld + 5 x 5 Average 5 x 5 gemiddeld + Author Title: Auteurstitel: + Author: Auteur: + Copyright Info URL: URL voor copyrightinfo: + Copyright Notice: Copyrightvermelding: + Copyright Status: Copyrightstatus: + Created: Aangemaakt: + Dates Datums + Description: Beschrijving: + Document Title: Documenttitel: + Keywords: Trefwoorden: + Modified: Gewijzigd: + Rating: Beoordeling: + Remove “{keyword}” „{keyword}” verwijderen + Separate several keywords with commas. Scheid meerdere trefwoorden met komma's. + Flare Tool Options Opties voor het Flare-gereedschap + Rings Ringen + Brightness: Helderheid: + Growth: Groei: + Fuzziness: Vagheid: + Number: Aantal: + Longest: Langste: + Path: Pad: + Largest: Grootste: + Anti-alias Rasters Anti-aliasing voor rasters + Clip Complex Regions Complexe gebieden bijsnijden + Convert All Strokes to Outlines Alle lijnen omzetten in omlijningen + Convert All Text to Outlines Alle tekst omzetten in omlijningen + Gradient and Mesh Resolution: Resolutie voor verlopen en verlooprasters: + Line Art and Text Resolution: Resolutie voor lijntekeningen en tekst: + Preserve Alpha Transparency Alfatransparantie behouden + Preserve Overprints and Spot Colors Overprints en spotkleuren behouden + Rasters Rasters + Replaces the saved preset of that name. Vervangt de opgeslagen voorinstelling met die naam. + Save Preset… Voorinstelling opslaan… + Vectors Vectoren + A saved copy of the selected preset Een opgeslagen kopie van de geselecteerde voorinstelling + Built-in presets don't change: New makes a copy you can edit. Ingebouwde voorinstellingen veranderen niet: Nieuw maakt een kopie die je kunt bewerken. + Horizontal % Horizontaal % + Include Cmy Blacks CMY-zwart opnemen + Make all bleed settings the same Alle afrullingsinstellingen gelijk maken + Make the bleed values differ De afrullingswaarden laten verschillen + Miter Limit: Hoeklimiet: + Radius 1 Radius 1 + Radius 2 Radius 2 + Radius1 Radius1 + Radius2 Radius2 + Relative Relatief + Absolute Absoluut + Vertical % Verticaal % + Miter Hoekaansluiting + Bevel Schuine aansluiting + A lower value follows the stroke closely; a higher one gives fewer points and smoother curves. Een lagere waarde volgt de lijn nauwkeurig; een hogere geeft minder punten en vloeiender curves. + Accurate Nauwkeurig + Close paths when ends are within: Paden sluiten wanneer de einden binnen: + Edit selected paths within: Geselecteerde paden bewerken binnen: + Fidelity: Nauwkeurigheid: + Fill new strokes Nieuwe lijnen vullen + The document has no colour swatches. Het document heeft geen kleurstalen. + Style Name: Stijlnaam: + Crop To Bijsnijden tot + e.g. 1-3, 5 bijv. 1-3, 5 + First Page Eerste pagina + Import PDF PDF importeren + Last Page Laatste pagina + Next Page Volgende pagina + of {count} van {count} + of van + Previous Page Vorige pagina + “{file}” is protected by a password. „{file}” is beveiligd met een wachtwoord. + Dim Images to: Afbeeldingen dimmen tot: + Light Blue Lichtblauw + Layers Panel Options Opties voor het paneel Lagen + Row Size: Rijgrootte: + Show Layers Only Alleen lagen weergeven + Thumbnails: Miniaturen: + Anchor Points Ankerpunten + Brush Affects: Penseel beïnvloedt: + Complexity: Complexiteit: + Detail: Detail: + Global Brush Dimensions Globale penseelafmetingen + Horizontal: Horizontaal: + In Tangent Handles Ingaande raakgrepen + Out Tangent Handles Uitgaande raakgrepen + Show Brush Size Penseelgrootte weergeven + Simplify: Vereenvoudigen: + Twirl Rate: Wervelsnelheid: + Use Pressure Pen Drukgevoelige pen gebruiken + Vertical: Verticaal: + {shortcut} with the tool sizes the brush {shortcut} samen met het gereedschap wijzigt de penseelgrootte + {tool} Options Opties voor {tool} + {tool} Tool Options Opties voor {tool}gereedschap + These fonts aren't available. Their text shows in another font. Deze lettertypen zijn niet beschikbaar. De tekst ervan wordt in een ander lettertype weergegeven. + Search Fonts Folder Lettertypenmap zoeken + Add Fonts Lettertypen toevoegen + Find Fonts Lettertypen zoeken + Stop Stoppen + Searching… Zoeken… + Not found Niet gevonden + {folders} folder(s) and {files} file(s) searched {folders} map(pen) en {files} bestand(en) doorzocht + {count} folder(s) skipped, such as other apps' and the system's folders and hidden ones {count} map(pen) overgeslagen, zoals mappen van andere apps, van het systeem en verborgen mappen + {count} folder(s) or file(s) couldn't be read {count} map(pen) of bestand(en) konden niet worden gelezen + The search stopped at its limit: search a smaller folder. De zoekopdracht is gestopt bij de limiet: doorzoek een kleinere map. + Copy only fonts you own or are licensed to install. Kopieer alleen lettertypen die je bezit of waarvoor je een licentie hebt om ze te installeren. + VectorCraft copies the fonts to {folder}. VectorCraft kopieert de lettertypen naar {folder}. + Apply to All ({count} more missing) Op alles toepassen (er ontbreken nog {count}) + Ignore Negeren + Linked File Not Found Gekoppeld bestand niet gevonden + Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Vervang het door een ander bestand of negeer het: de afbeeldingen blijven de voorbeeldweergave tonen die met het document is opgeslagen. + Replace… Vervangen… + Update Modified Links Gewijzigde koppelingen bijwerken + {count} linked image(s) changed since this document was saved. Show the new versions? {count} gekoppelde afbeelding(en) zijn gewijzigd sinds dit document is opgeslagen. De nieuwe versies weergeven? + Dialog Dialoogvenster + Convert Process to Global Proces omzetten naar globaal + Create From: Maken van: + Include Swatches for Tints Stalen voor tinten opnemen + Selected Artwork Geselecteerde artwork + Selected Swatches Geselecteerde stalen + Advanced Options Geavanceerde opties + Background Contents Achtergrondinhoud + Background Contents: Achtergrondinhoud: + BLANK DOCUMENT PRESETS LEGE DOCUMENTVOORINSTELLINGEN + Bleed Afrulling + Change to Right-to-Left Layout Omschakelen naar lay-out van rechts naar links + Columns: Kolommen: + Create Document Document maken + Documents you create appear here. Documenten die je maakt, verschijnen hier. + More Settings Meer instellingen + New from Template Nieuw uit sjabloon + Number of Artboards: Aantal tekenborden: + Orientation: Oriëntatie: + PRESET DETAILS VOORINSTELLINGSDETAILS + Preset Name Voorinstellingsnaam + Preset {} Voorinstelling {} + Presets you save with the Save Preset button appear here. Voorinstellingen die je opslaat met de knop Voorinstelling opslaan, verschijnen hier. + Preview Mode Voorbeeldmodus + RECENT RECENT + RECENT (1) RECENT (1) + SAVED OPGESLAGEN + Templates… Sjablonen… + Transparent (Default) Transparant (standaard) + Save for Office Documents Opslaan voor Office-documenten + Transparent Background Transparante achtergrond + Choose… Kies… + Copy Fonts Used in the Document In het document gebruikte lettertypen kopiëren + Copy Links Koppelingen kopiëren + Create Report Rapport maken + Fonts whose licence doesn't allow embedding are not copied. Lettertypen waarvan de licentie insluiten niet toestaat, worden niet gekopieerd. + Package collects a saved document with its linked files and fonts. Verpakken verzamelt een opgeslagen document met zijn gekoppelde bestanden en lettertypen. + Package created Pakket gemaakt + Packaged {n} file(s) in {folder}{note} {n} bestand(en) verpakt in {folder}{note} + Packaged {n} file(s){note} {n} bestand(en) verpakt{note} + Relink Linked Files to the Document Gekoppelde bestanden opnieuw koppelen aan het document + Save the document first? Wil je het document eerst opslaan? + {count} file(s) in {folder}{note}. Show the package folder? {count} bestand(en) in {folder}{note}. De pakketmap openen? + {count} linked file(s) not found {count} gekoppelde bestand(en) niet gevonden + fonts not copied: {fonts} niet gekopieerde lettertypen: {fonts} + Built-in presets are read-only: New… starts an editable copy. Ingebouwde voorinstellingen zijn alleen-lezen: Nieuw… start een bewerkbare kopie. + 1 {unit} on the artboard stands for {real} {unit} in the scene. 1 {unit} op het tekenbord staat voor {real} {unit} in de scène. + Grid Color & Opacity Rasterkleur & dekking + Gridline every: Rasterlijn elke: + Horizon Height: Horizonhoogte: + Horizontal Grid: Horizontaal raster: + Left Grid: Linkerraster: + Perspective Grid Settings Instellingen voor perspectiefraster + Real World: Werkelijke wereld: + Right Grid: Rechterraster: + Save Grid as Preset Raster opslaan als voorinstelling + Save these settings as a preset Deze instellingen als voorinstelling opslaan + Third Vanishing Point: Derde vluchtpunt: + Viewing Angle: Kijkhoek: + Viewing Distance: Kijkafstand: + Show Active Plane Widget Widget voor actief vlak weergeven + Widget Position: Widgetpositie: + Copy All Objects Alle objecten kopiëren + Do Not Move Niet verplaatsen + Horizontal Plane Horizontaal vlak + Left Plane Linkervlak + Move All Objects Alle objecten verplaatsen + Right Plane Rechtervlak + Built-in presets don't change: New… starts an editable copy. Ingebouwde voorinstellingen veranderen niet: Nieuw… start een bewerkbare kopie. + One Point Eén vluchtpunt + Perspective grid presets Voorinstellingen voor perspectiefraster + Three Point Drie vluchtpunten + Two Point Twee vluchtpunten + User Preset Gebruikersvoorinstelling + Click to place each file at 100%, or drag to size it; arrow keys switch files, Esc skips one. Klik om elk bestand op 100% te plaatsen of sleep om het te schalen; met de pijltjestoetsen wissel je van bestand, Esc slaat er een over. + Keep a link to an image file instead of only embedding it Een koppeling met een afbeeldingsbestand behouden in plaats van het alleen in te sluiten + Place onto a new locked, dimmed template layer below the current layer Plaatsen op een nieuwe, vergrendelde, gedimde sjabloonlaag onder de huidige laag + Swap the selected object for the file, keeping its place and transform Het geselecteerde object vervangen door het bestand, met behoud van positie en transformatie + On: one locked object showing the document, updated when it changes. Aan: één vergrendeld object dat het document weergeeft, bijgewerkt wanneer het verandert. + Off: an editable copy of its art, embedded in this document. Uit: een bewerkbare kopie van de artwork, ingesloten in dit document. + Images: link to the file instead of only embedding it. Afbeeldingen: koppel aan het bestand in plaats van het alleen in te sluiten. + VectorCraft documents: one locked object, updated when the file changes. VectorCraft-documenten: één vergrendeld object, bijgewerkt wanneer het bestand verandert. + Alignment: Uitlijning: + Applies when the linked file is relinked or updated. Is van toepassing wanneer het gekoppelde bestand opnieuw gekoppeld of bijgewerkt wordt. + Bounds (Fill Proportionally) Begrenzing (proportioneel vullen) + Bounds (Fit Proportionally) Begrenzing (proportioneel passend maken) + File Dimensions Bestandsafmetingen + Transforms Transformaties + Background Color: Achtergrondkleur: + Client-side (.html) Client-side (.html) + Color Reduction: Kleurreductie: + Lossless (lossy WebP isn't available yet) Verliesvrij (WebP met verlies is nog niet beschikbaar) + Scans: Scans: + Server-side (.map) Server-side (.map) + {format} Options Opties voor {format} + 0.2 (quality) to 100 (speed) 0.2 (kwaliteit) tot 100 (snelheid) + All the art Alle artwork + Artboard {number} Tekenbord {number} + Auto-Rotate (turn the paper to each artboard) Automatisch draaien (draai het papier naar elk tekenbord) + Automatic flatness Automatische vlakheid + Can't print these settings: see the Summary Deze instellingen kunnen niet worden afgedrukt: zie het overzicht + CMYK colours print with their own values; off, they go through the colour settings too. CMYK-kleuren drukken af met hun eigen waarden; uitgeschakeld gaan ze ook door de kleurinstellingen. + Collate Sorteren + Composite colours are converted to it; separations separate with it when it is a CMYK profile. Composietkleuren worden ernaar geconverteerd; separaties worden ermee gescheiden als het een CMYK-profiel is. + Convert All Spot Colors to Process Alle spotkleuren omzetten naar proceskleuren + Default Printer Standaardprinter + Document Ink Options Inktopties voor document + Download: Downloaden: + Drag the art to move it on the paper Sleep de artwork om hem op het papier te verplaatsen + Each page prints as one image, at the document's raster effects resolution. Elke pagina wordt afgedrukt als één afbeelding, met de resolutie voor rastereffecten van het document. + Edit Print Preset Afdrukvoorinstelling bewerken + Emulsion: Emulsie: + Every option is at its default. Elke optie staat op de standaardwaarde. + Flatness: Vlakheid: + Frequencies and angles are the output device's to apply. Frequenties en hoeken worden door het uitvoerapparaat toegepast. + General › Placement › X Algemeen › Plaatsing › X + How colours outside the press's gamut are separated. Hoe kleuren buiten het gamut van de drukpers worden gescheiden. + Ignore Artboards (all the art as one page) Tekenborden negeren (alle artwork als één pagina) + Image: Afbeelding: + Marks and Bleed Drukkerstekens en afrulling + Media Media + New Print Preset Nieuwe afdrukvoorinstelling + Next page Volgende pagina + No printer settings to open here Geen printerinstellingen om hier te openen + None (keep transparency) Geen (transparantie behouden) + Nothing to print Niets om af te drukken + Open a document to preview its pages Open een document om een voorbeeld van de pagina's te bekijken + Output › Mode Uitvoer › Modus + Overprint and Transparency Flattener Options Opties voor overprint en transparantiepletter + Place the pages with the placement again Plaats de pagina's opnieuw met de plaatsing + Placement: Plaatsing: + Preserve CMYK Numbers CMYK-waarden behouden + Previous page Vorige pagina + Print Layers: Lagen afdrukken: + Print Preset: Afdrukvoorinstelling: + Printer Printer + Printer profile: Printerprofiel: + Printer: Printer: + Rendering intent: Renderintentie: + Same As Source Zelfde als bron + Save these settings as a print preset Deze instellingen als afdrukvoorinstelling opslaan + Scaling: Schaling: + Separations list the document's inks here: one page per ink that prints. Separaties geven hier de inkten van het document weer: één pagina per inkt die wordt afgedrukt. + Setup… Instellen… + Skip Blank Artboards Lege tekenborden overslaan + The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. De rand van het papier waar de printer niet kan komen: de artwork valt daarbinnen en de voorbeeldweergave toont hem gestippeld. + The page as it prints De pagina zoals deze wordt afgedrukt + The Print Tiling tool placed the pages. Het gereedschap Afdruktegels heeft de pagina's geplaatst. + The system's settings of this printer De systeeminstellingen van deze printer + Tile Full Pages Volledige pagina's tegelen + Tile Range: Tegelbereik: + tile {number} tegel {number} + Tiles are numbered across, then down; leave the range empty for every tile. Tegels worden van links naar rechts en vervolgens van boven naar beneden genummerd; laat het bereik leeg voor alle tegels. + Transverse (the page a quarter turn on the paper) Dwars (de pagina een kwartslag op het papier) + Unprintable margin: Niet afdrukbare marge: + {pages} in all, {count} per copy {pages} in totaal, {count} per exemplaar + {page} of {count} {page} van {count} + PDF File PDF-bestand + [Default] is protected: New… starts an editable copy. [Standaard] is beschermd: Nieuw… start een bewerkbare kopie. + A new preset starting from the selected one Een nieuwe voorinstelling, beginnend bij de geselecteerde + Differences from {preset}: Verschillen met {preset}: + Differences from [Default]: Verschillen met [Standaard]: + Differences from VectorCraft Default: Verschillen met de VectorCraft-standaard: + Edit… Bewerken… + None. Geen. + Presets: Voorinstellingen: + Save the selected preset to a file De geselecteerde voorinstelling naar een bestand opslaan + Embed ICC Profile ICC-profiel insluiten + Flat Image Platte afbeelding + Include Hidden Layers Verborgen lagen opnemen + Maximum Editability Maximale bewerkbaarheid + Options: Opties: + Write Layers Lagen wegschrijven + Anti-alias Anti-alias + Around Object Rond object + Background: Achtergrond: + Changing these settings changes how raster effects look when exported, flattened or expanded. Als je deze instellingen wijzigt, verandert hoe rastereffecten eruitzien bij exporteren, pletten of uitbreiden. + Color Model: Kleurmodel: + Create Clipping Mask Knipmasker maken + Preserve Spot Colors Spotkleuren behouden + Resolution: Resolutie: + Transparent Transparant + Layout Order: Indelingsvolgorde: + Change to Left-to-Right Layout Omschakelen naar lay-out van links naar rechts + Artboards: {n} Tekenborden: {n} + Rows: Rijen: + Assign Toewijzen + Color Group: Kleurgroep: + Color Library Kleurbibliotheek + Colors: Kleuren: + Current Colors ({total}) Huidige kleuren ({total}) + Current Colors Huidige kleuren + Current Colors (0) Huidige kleuren (0) + Harmony Rules: Harmonieregels: + Harmony Rules Harmonieregels + Keep this row's colors (exclude) De kleuren van deze rij behouden (uitsluiten) + Limit to Library: Beperken tot bibliotheek: + Link harmony colors Harmoniekleuren koppelen + Merge colors into one row Kleuren samenvoegen in één rij + No colors to recolor (preserved colors are left out). Geen kleuren om opnieuw te kleuren (behouden kleuren zijn weggelaten). + Preserve: Behouden: + Randomly change color order Kleurvolgorde willekeurig wijzigen + Randomly change saturation and brightness Verzadiging en helderheid willekeurig wijzigen + Recolor this row Deze rij opnieuw kleuren + Reset the rows to the artwork's colors De rijen herstellen naar de kleuren van de artwork + Separate colors into rows Kleuren over rijen verdelen + Unlink harmony colors Harmoniekleuren ontkoppelen + 1 Color Job Opdracht in 1 kleur + 2 Color Job Opdracht in 2 kleuren + 3 Color Job Opdracht in 3 kleuren + Recover Documents Documenten herstellen + Restored documents open unsaved: save them to keep them. Herstelde documenten worden onopgeslagen geopend: sla ze op om ze te bewaren. + VectorCraft didn't quit normally last time. These documents had unsaved changes: VectorCraft is de vorige keer niet normaal afgesloten. Deze documenten hadden niet-opgeslagen wijzigingen: + Do you want to save the changes you made to “{name}”? Wil je de wijzigingen die je in „{name}” hebt aangebracht, opslaan? + Don't Save Niet opslaan + Your changes will be lost if you don't save them. Je wijzigingen gaan verloren als je ze niet opslaat. + < 1 sec < 1 sec + [Unnamed] [Naamloos] + Amount: Hoeveelheid: + Anti-aliasing: Anti-aliasing: + Artboard: Tekenbord: + Color Table Kleurentabel + Color Table (4 colors) Kleurentabel (4 kleuren) + Color Table ({n} color) Kleurentabel ({n} kleur) + Color Table ({n} colors) Kleurentabel ({n} kleuren) + Convert to sRGB Converteren naar sRGB + Delete this saved preset Deze opgeslagen voorinstelling verwijderen + Dither: Dither: + Downloaded {} file(s) {} bestand(en) gedownload + Fit in View In beeld passen + Format: Indeling: + WebP WebP + High Hoog + ICC Profile ICC-profiel + Image Size Afbeeldingsgrootte + Interlaced Interlaced + Lossy: Met verlies: + Low Laag + Matte: Mat: + Maximum Maximum + Metadata: Metagegevens: + Needs the desktop app Vereist de desktop-app + Optimized Geoptimaliseerd + Order: Volgorde: + Original Origineel + 2-Up 2 naast elkaar + 4-Up 4 naast elkaar + Output: Uitvoer: + Percent: Procent: + Preview in Browser needs the desktop app (the web downloads files instead) Voorbeeld in browser vereist de desktop-app (op het web worden bestanden gedownload) + Progressive Progressief + Quality {quality} Kwaliteit {quality} + Quality: Kwaliteit: + Reduction: Reductie: + Save the settings as a preset De instellingen als voorinstelling opslaan + Saved {n} files beside {path} {n} bestanden opgeslagen naast {path} + Shown with the optimised preview. Weergegeven met de geoptimaliseerde voorbeeldweergave. + Slices: Slices: + Unlock Ontgrendelen + Unshift Verschuiving ongedaan maken + Very High Zeer hoog + Web Shift Webverschuiving + Web Snap: Websnap: + {secs} sec {secs} sec + Create PDF-Compatible File PDF-compatibel bestand maken + Embed ICC Profiles ICC-profielen insluiten + File Name: Bestandsnaam: + Save As Opslaan als + Save as Template Opslaan als sjabloon + Save each artboard to a separate file Elk tekenbord in een apart bestand opslaan + All printer's marks Alle drukkerstekens + Bleeds Afrullingen + Bottom: Onder: + Changes allowed: Toegestane wijzigingen: + Color bars Kleurcontrolestroken + Color conversion: Kleurconversie: + Color images Kleurafbeeldingen + Compatibility: Compatibiliteit: + PDF 1.7 PDF 1.7 + VectorCraft Default VectorCraft-standaard + High Quality Print Hoge afdrukkwaliteit + [High Quality Print] [Hoge afdrukkwaliteit] + Press Quality Perskwaliteit + [Press Quality] [Perskwaliteit] + Smallest File Size Kleinste bestandsgrootte + [Smallest File Size] [Kleinste bestandsgrootte] + PDF/X-1a:2001 PDF/X-1a:2001 + [PDF/X-1a:2001] [PDF/X-1a:2001] + PDF/X-3:2002 PDF/X-3:2002 + [PDF/X-3:2002] [PDF/X-3:2002] + PDF/X-4:2010 PDF/X-4:2010 + [PDF/X-4:2010] [PDF/X-4:2010] + Keeps the document editable: VectorCraft reopens the PDF with nothing lost. For files you'll edit again. Behoudt de bewerkbaarheid van het document: VectorCraft opent de PDF opnieuw zonder dat er iets verloren gaat. Voor bestanden die je opnieuw gaat bewerken. + Compress text and line art Tekst en lijnwerk comprimeren + Compression Compressie + Compression › Color › Above Ppi Compressie › Kleur › Boven Ppi + Compression: Compressie: + Condition identifier: Voorwaarde-id: + Convert text to outlines Tekst omzetten in omlijningen + Create PDF layers from layers and sublayers PDF-lagen maken van lagen en sublagen + Description Beschrijving + Destination: Bestemming: + Document open password Wachtwoord voor openen van document + Document profile Documentprofiel + Edit PDF Preset PDF-voorinstelling bewerken + Embed page thumbnails Miniatuurafbeeldingen van pagina's insluiten + Enable copying of text, images and other content Kopiëren van tekst, afbeeldingen en andere inhoud toestaan + Enable plaintext metadata Metadata als platte tekst toestaan + Enable text access for screen readers Tekstoegang voor schermlezers toestaan + Encryption: {name} (set by Compatibility) Versleuteling: {name} (ingesteld via Compatibiliteit) + Error Fout + Every option matches {base}. Elke optie komt overeen met {base}. + Files of a PDF standard can't be password-protected. Bestanden volgens een PDF-standaard kunnen niet met een wachtwoord worden beveiligd. + Files of this standard tag their colours with ICC profiles. Bestanden volgens deze standaard voorzien hun kleuren van ICC-profielen. + Flattener preset: Voorinstelling voor afvlakking: + General › Thumbnails Algemeen › Miniaturen + Grayscale images Grijswaardenafbeeldingen + Include non-printing layers Niet-afdrukbare lagen opnemen + Left: Links: + Mark as trapped Markeren als getrapt + Marks and Bleeds Steker en afloop + Monochrome images Monochrome afbeeldingen + Monochrome Monochroom + New PDF Preset Nieuwe PDF-voorinstelling + of characters used van de gebruikte tekens + Offset: Offset: + On Aan + Open password: Wachtwoord voor openen: + Optimize for fast web view Optimaliseren voor snelle webweergave + Output condition: Uitvoervoorwaarde: + Output Intent Uitvoerintentie + Output intent profile: Profiel voor uitvoerintentie: + Overprint and Transparency Flattener Overprint en transparantieafvlakker + Overprint: Overprint: + Page information Pagina-informatie + PDF 1.3 files have no transparency: the preset flattens it. PDF 1.3-bestanden bevatten geen transparantie: de voorinstelling vlakt deze af. + PDF/A files carry their own output intent. PDF/A-bestanden bevatten hun eigen uitvoerintentie. + PDF/X-1a files are CMYK: every colour is converted to the destination (blank: the output intent's CMYK profile), untagged. PDF/X-1a-bestanden zijn CMYK: elke kleur wordt geconverteerd naar de bestemming (leeg: het CMYK-profiel van de uitvoerintentie), ongetagd. + Permissions Machtigingen + Permissions password: Wachtwoord voor machtigingen: + Preserve editing capabilities Bewerkbaarheid behouden + Preset name: Naam van voorinstelling: + Printer mark type: Type steker: + Printing allowed: Afdrukken toegestaan: + Profile inclusion: Profiel opnemen: + Range: Bereik: + Registration marks Registratiestekers + Registry name: Registernaam: + Require a password to open the document Een wachtwoord vereisen om het document te openen + Restrict printing, editing and other tasks Afdrukken, bewerken en andere taken beperken + Right: Rechts: + Save as preset: Opslaan als voorinstelling: + Save PDF PDF opslaan + Security Beveiliging + Standard: Standaard: + Subset fonts below: Lettertypes subsetten onder: + the preset de voorinstelling + Top: Boven: + Trim mark weight: Bijsnijstekerdikte: + Trim marks Bijsnijstekers + Use document bleed settings Afloopinstellingen van document gebruiken + View PDF after saving PDF na het opslaan weergeven + Warnings Waarschuwingen + CMYK profile in effect Toegepast CMYK-profiel + Save Graphic Style Library Bibliotheek met grafische stijlen opslaan + Selected Styles Only Alleen geselecteerde stijlen + Selected Styles Only (0) Alleen geselecteerde stijlen (0) + A File… Een bestand… + GPL palettes keep solid colours only, as RGB. GPL-paletten bevatten alleen effen kleuren, als RGB. + Save Swatch Library Bibliotheek met stalen opslaan + VectorCraft Swatches (.vcswatches) VectorCraft-stalen (.vcswatches) + Save To: Opslaan in: + Saved to User Defined: {} Opgeslagen in Door gebruiker gedefinieerd: {} + Selected Swatches Only Alleen geselecteerde stalen + Selected Swatches Only (0) Alleen geselecteerde stalen (0) + User Defined Libraries Door gebruiker gedefinieerde bibliotheken + Copy Report Rapport kopiëren + VectorCraft read this script without running it. VectorCraft heeft dit script gelezen zonder het uit te voeren. + Needs nothing beyond plain JavaScript Heeft alleen gewone JavaScript nodig + Needs the script language's own objects or directives Heeft de eigen objecten of richtlijnen van de scripttaal nodig + Needs the document model Heeft het documentmodel nodig + Needs files Heeft bestanden nodig + Needs menu commands Heeft menuopdrachten nodig + Needs dialogs Heeft dialoogvensters nodig + Needs features only the reference app has Heeft functies nodig die alleen de referentie-app heeft + Outside the document Buiten het document + Writes, moves or deletes files Schrijft, verplaatst of verwijdert bestanden + Opens files in other apps Opent bestanden in andere apps + Opens network connections Opent netwerkverbindingen + Loads native code Laadt native code + Sends messages to other apps Stuurt berichten naar andere apps + Reads or sets environment variables Leest of stelt omgevingsvariabelen in + Runs code the checker can't read Voert code uit die het controleprogramma niet kan lezen + Changes the reference app's preferences Wijzigt de voorkeuren van de referentie-app + Menu commands Menuopdrachten + Names the script builds as it runs: {count} Namen die het script tijdens het uitvoeren genereert: {count} + Document model Documentmodel + Object types: {names} Objecttypen: {names} + Directives Richtlijnen + Includes Includes + Checked Gecontroleerd + Not followed: outside the script's folder Niet gevolgd: buiten de map van het script + Includes itself Includes zichzelf + Not read Niet gelezen + Not read: a limit was reached Niet gelezen: een limiet is bereikt + Compiled or not text Gecompileerd of geen tekst + Problems Problemen + String not closed Tekenreeks niet afgesloten + Comment not closed Commentaar niet afgesloten + Regular expression not closed Reguliere expressie niet afgesloten + Template literal not closed Template literal niet afgesloten + XML literal not closed XML-literal niet afgesloten + Bracket never closed Haakje nooit gesloten + Closing bracket with no opening one Sluitend haakje zonder openingshaakje + Syntax newer than ES3 Syntax nieuwer dan ES3 + Unknown directive Onbekende richtlijn + Not valid UTF-8: unreadable bytes were replaced Geen geldige UTF-8: onleesbare bytes zijn vervangen + Mixed line endings Gemengde regeleinden + Nesting too deep to follow Nesting te diep om te volgen + line {line} regel {line} + lines {lines} regels {lines} + {file}, line {line} {file}, regel {line} + Compiled script: ask its author for the .jsx file Gecompileerd script: vraag de auteur om het .jsx-bestand + Too large to check Te groot om te controleren + Not a script for the reference app Geen script voor de referentie-app + Some lists were cut short at their limit. Sommige lijsten zijn afgekapt bij hun limiet. + Line Segment Tool Options Opties lijnsegmentgereedschap + Alt: Alt: + Divide Horizontally Into Horizontaal verdelen in + Divide Vertically Into Verticaal verdelen in + Matte Mat + Message: Bericht: + N slices …, evenly spaced N slices …, gelijkmatig verdeeld + per slice per slice + Slice Type: Slice-type: + slices across, evenly spaced slices horizontaal, gelijkmatig verdeeld + slices down, evenly spaced slices verticaal, gelijkmatig verdeeld + Target: Doel: + Text Displayed in Cell: Tekst weergegeven in cel: + The cell shows the type object's text as HTML. De cel toont de tekst van het tekstobject als HTML. + Lab Spot Colors: Lab-steunkleuren: + Lab Spot Colors Lab-steunkleuren + Lab Values Lab-waarden + Show and print from the Lab definition; PDF files carry it as the ink's alternate color. Weergeven en afdrukken volgens de Lab-definitie; PDF-bestanden bevatten deze als alternatieve kleur van de inkt. + Show and separate through the working CMYK space, as in older documents. Weergeven en scheiden via de CMYK-werkruimte, zoals in oudere documenten. + CMYK Equivalents CMYK-equivalenten + Convert To Outlines Omzetten in omlijningen + Decimal: Decimalen: + Embed Fonts (Glyphs Used) Lettertypen insluiten (gebruikte glyphs) + Fewer Elements Minder -elementen + Font: Lettertype: + Hide Code Code verbergen + Images: Afbeeldingen: + Include Metadata Metadata opnemen + Internal CSS Interne CSS + Layer Names Laagnamen + Minify Minify + Minimal Minimaal + Object IDs: Object-id's: + Presentation Attributes Presentatieattributen + Preserve Editing Capabilities Bewerkbaarheid behouden + Responsive Responsive + Show Code Code weergeven + Style Attributes Stijlattributen + Style Attributes (Entity References) Stijlattributen (entiteitsverwijzingen) + Styling: Stijl: + SVG Profile: SVG-profiel: + SVG 1.1 SVG 1.1 + Unicode (UTF-16) Unicode (UTF-16) + Unicode (UTF-8) Unicode (UTF-8) + Unique Uniek + Conflict {n} of {count} Conflict {n} van {count} + Keep the pasted color as a new swatch with a numbered name. De geplakte kleur als nieuw staal met een genummerde naam behouden. + Pasted Geplakt + This document already has a swatch named “{name}” with another color. Dit document heeft al een staal met de naam „{name}” in een andere kleur. + Use the document's swatch: pasted colors take its color. Het staal van het document gebruiken: geplakte kleuren nemen de kleur ervan over. + Color Type: Kleurtype: + Swatch Name: Staalnaam: + Diameter: Diameter: + Symbolism Tools Options Opties symbolisatiegereedschappen + Symbol Set Density: Symboolsetdichtheid: + Intensity: Intensiteit: + Place PDF PDF plaatsen + Collect Links in a Separate Folder Koppelingen in een afzonderlijke map verzamelen + Folder Name: Mapnaam: + Color Management Kleurbeheer + Summary Samenvatting + Download Downloaden + Export Artboard Tekenbord exporteren + Encoding: Codering: + UTF-16 (Unicode) UTF-16 (Unicode) + Line Endings: Regeleinden: + LF (macOS, Linux) LF (macOS, Linux) + CRLF (Windows) CRLF (Windows) + Text Export Options Opties tekstexport + Character Set: Tekenset: + Join the lines of each paragraph De regels van elke alinea samenvoegen + Line Breaks Regeleinden + Mac Mac + or more spaces with a tab of meer spaties door een tab + Platform: Platform: + Remove blank lines between paragraphs Lege regels tussen alinea's verwijderen + Spaces Spaties + Text Import Options Opties tekstimport + Unicode Unicode + Byte Order: Bytevolgorde: + Compress (RLE) Comprimeren (RLE) + Depth: Diepte: + File Format: Bestandsindeling: + Flip Row Order Rijvolgorde omkeren + LZW Compression LZW-compressie + Copy (make a transformed copy) Kopiëren (een getransformeerde kopie maken) + Reference Point Referentiepunt + Reflect X Spiegelen X + Reflect Y Spiegelen Y + Kind Soort + Variable Variabele + no data set to edit geen gegevensset om te bewerken + New Name Nieuwe naam + no variable to rename geen variabele om te hernoemen + no data set to rename geen gegevensset om te hernoemen + Variable Options Variabeleopties + Define a variable first Definieer eerst een variabele + Adjust Adjoining Width Points Aangrenzende breedtepunten aanpassen + Keep both sides the same width Beide zijden dezelfde breedte houden + Side 1: Zijde 1: + Side 2: Zijde 2: + Total Width: Totale breedte: + Width Point Edit Breedtepunt bewerken + Art Illustratie + Bristle Borstel + Calligraphic Kalligrafisch + Dots Stippen + Fixed Vast + Pressure Druk + Random Willekeurig + Scatter Strooi + Color Burn Na branden + Color Dodge Kleur ontwijken + Darken Donkerder + Exclusion Uitsluiting + Hard Light Hard licht + Lighten Lichter + Luminosity Luminositeit + Overlay Bedekken + Soft Light Zacht licht + Crosshatch Kruisarcering + Fine Dots Fijne stippen + Medium Dots Middelgrote stippen + Grainy Dots Korrelige stippen + Coarse Dots Grove stippen + Short Lines Korte lijnen + Medium Lines Middellange lijnen + Long Lines Lange lijnen + Short Strokes Korte streken + Medium Strokes Middellange streken + Long Strokes Lange streken + Soft Zacht + Screen Scherm +axis Horizontal Horizontaal +axis Vertical Verticaal + Sprinkles Sprenkels + Clumped Klonterig + Contrasty Contrasterend + Enlarged Vergroot + Stippled Gestippeld + Speckle Spikkels + Brick Baksteen + Burlap Jute + Canvas Canvas + Sandstone Zandsteen + Bottom Left Linksonder + Top Left Linksboven + Top Right Rechtsboven + Bottom Right Rechtsonder +effect Soft Zacht + Blocks Blokken + Frosted Mat + Tiny Lens Kleine lens + Right Diagonal Diagonaal rechts + Left Diagonal Diagonaal links +effect Horizontal Horizontaal +effect Vertical Verticaal + Diamond Ruit + Ellipse Ellips + Add Effect Effect toevoegen + Add New Fill Nieuwe vulling toevoegen + Add New Stroke Nieuwe streek toevoegen + Appearance Item Vormgeving-item + Clear Appearance Vormgeving wissen + Duplicate Item Item dupliceren + Eyedropper Pipet + Move Appearance Vormgeving verplaatsen + Reduce to Basic Appearance Vormgeving terugbrengen naar basis + Remove Item Item verwijderen + Reorder Appearance Item Vormgeving-item opnieuw ordenen + Select Appearance Item Vormgeving-item selecteren + Show All Hidden Attributes Alle verborgen kenmerken weergeven + Target Contents Inhoud als doel instellen +theme Appearance Vormgeving + Asset Export Settings Exportinstellingen voor assets + Assets Assets + Export Assets Assets exporteren + Remove Asset Asset verwijderen + Rename Asset Asset hernoemen + Fill Rule Vulregel + Apply Brush Kwast toepassen + Break Link to Symbol Koppeling met symbool verbreken + Brush Definition Kwastdefinitie + Brush Options… Kwastopties… + Current Brush Huidige kwast + Current Symbol Huidige symbool + Delete Brush Kwast verwijderen + Delete Symbol Symbool verwijderen + Duplicate Brush Kwast dupliceren + Duplicate Symbol Symbool dupliceren + Edit Symbol Symbool bewerken + Expand Brush Strokes Kwaststreken omzetten + New Brush… Nieuwe kwast… + New Symbol Nieuw symbool + New Symbol… Nieuw symbool… + Place Symbol Instance Symboolexemplaar plaatsen + Redefine Symbol Symbool opnieuw definiëren + Remove Brush Stroke Kwaststreek verwijderen + Replace Symbol Symbool vervangen + Select All Instances Alle exemplaren selecteren + Symbol Sprayer Symboolspuit + Symbolism Tool Symbolisatiegereedschap + Image Trace Presets Voorinstellingen voor afbeeldingtracering + Live Paint Bucket Live Paint-emmer + Live Paint Bucket (Stroke) Live Paint-emmer (streek) + Live Paint Info Live Paint-info + Shape Builder Vormopbouw + Shape Builder Regions Vormopbouwregio's + Image Trace View Weergave afbeeldingtraceren + Edge Rand + Clipboard as PDF Klembord als PDF + Clipboard as PNG Klembord als PNG + Clipboard as Text Klembord als tekst + Clipboard Formats Klembordindelingen + Load Image into Clipboard Afbeelding in klembord laden + Load Metafile into Clipboard Metafile in klembord laden + Load PDF into Clipboard PDF in klembord laden + Load Text into Clipboard Tekst in klembord laden + Clipboard as SVG Klembord als SVG + Load SVG into Clipboard SVG in klembord laden + Swatch Conflicts Staalconflicten + Saturate Verzadigen + Convert Color Kleur omzetten + Convert Document Color Mode Kleurmodus van document omzetten + Gamut Warning Gamutwaarschuwing + Load Color Profile… Kleurprofiel laden… + Plates Platen + Spot Color Steunkleur + Add to Swatches Aan stalen toevoegen + Delete Theme Thema verwijderen + Save Theme Thema opslaan + dash streepje + Missing Ontbreekt + [Normal Paragraph Style] [Normale alineastijl] + Arc Boog + Auto Size New Area Type Automatische grootte voor nieuwe vlaktekst + Create Path Pad maken + Create Text Tekst maken + Drawing Mode Tekenmodus + Line Lijn + Line Segment Lijnsegment + Polar Grid Radiaal raster + Polygon Polygoon + Rectangular Grid Rechthoekig raster + Horizontal Dividers: Horizontale scheidingslijnen: + Vertical Dividers: Verticale scheidingslijnen: + Rounded Rectangle Afgeronde rechthoek + Spiral Spiraal + Export CSS CSS exporteren + Generate CSS CSS genereren + Line Cut Lijnsnede + Mirror & Cut Spiegelen & knippen + Rectangle Cut Rechthoeksnede + Active Perspective Plane Actief perspectiefvlak + Delete Width Point Breedtepunt verwijderen + Draw in Perspective In perspectief tekenen + Move in Perspective In perspectief verplaatsen + Move Plane Vlak verplaatsen + Nudge in Perspective In perspectief duwen + Puppet Warp Puppet Warp + Puppet Warp Pins Puppet Warp-pinnen + Show Perspective Grid Perspectiefraster weergeven + Width Point Breedtepunt + Width Profile Breedteprofiel + Objects Objecten + Paths Paden + Add Guide Hulplijn toevoegen + Edit Selection Selectie bewerken + Recall Selection Selectie oproepen + Remove Guide Hulplijn verwijderen + Saved Selections Opgeslagen selecties + Select Guides Hulplijnen selecteren + Move Guide to Layer Hulplijn naar laag verplaatsen + Blob Brush BLOB-kwast + Convert Anchor Point Ankerpunt omzetten + Curvature Kromming + Delete Anchor Point Ankerpunt verwijderen + Eraser Gom + Knife Mes + Path Eraser Padgom + Pencil Potlood + Reshape Segment Segment opnieuw vormgeven + Split Path Pad splitsen + Activate Document Document activeren + Delete Verwijderen + Document JSON Document-JSON + Duplicate Dupliceren + Find Objects Objecten zoeken + Inspect Document Document inspecteren + Inspect Object Object inspecteren + Begin Undo Group Groep voor ongedaan maken beginnen + End Undo Group Groep voor ongedaan maken beëindigen + Apply Effect Effect toepassen + Duplicate Effect Effect dupliceren + Effect Options Effectopties + Effects Effecten + Move Effect Effect verplaatsen + Remove Effect Effect verwijderen + Expand Options Omzetopties + File Info Bestandsinfo + Batch Batch + DXF Info DXF-info + Export Document Document exporteren + Export DXF DXF exporteren + Export EPS EPS exporteren + Export for Screens Exporteren voor schermen + Export for Screens Settings Instellingen voor schermexport + Export PDF PDF exporteren + File Formats Bestandsindelingen + Flattener presets Voorinstellingen voor afvlakking + Format Options Indelingsopties + Layer Laag + Link Koppeling + Model Model + Open Document Document openen + Password Wachtwoord + PDF Info PDF-info + PDF Settings PDF-instellingen + Print presets Afdrukvoorinstellingen + Save Document Document opslaan + Serialize Document Document serialiseren + Shapes Vormen + Outlines Omlijningen + Artboard Tekenbord + Windows Vensters + Delete Transparency Flattener Preset Voorinstelling voor transparantieafvlakking verwijderen + Export Transparency Flattener Presets Voorinstellingen voor transparantieafvlakking exporteren + Import Transparency Flattener Presets Voorinstellingen voor transparantieafvlakking importeren + Save Transparency Flattener Preset Voorinstelling voor transparantieafvlakking opslaan + Transparency Flattener Presets Voorinstellingen voor transparantieafvlakking + to tot + Missing Fonts Ontbrekende lettertypen + Find Font Files Lettertypebestanden zoeken + Add Font Files Lettertypebestanden toevoegen + Font List Lettertypelijst + Fonts in Document Lettertypen in document + Refresh Font List Lettertypelijst vernieuwen + Replace Font Lettertype vervangen + Select Text by Font Tekst selecteren op lettertype + Add Freeform Line Lijn in vrije vorm toevoegen + Add Freeform Point Punt in vrije vorm toevoegen + Delete Freeform Point Punt in vrije vorm verwijderen + Edit Freeform Point Punt in vrije vorm bewerken + Freeform Gradient Verloop in vrije vorm + Select Freeform Point Punt in vrije vorm selecteren + Split Freeform Line Lijn in vrije vorm splitsen + Gradient Vector Verloopvector + Select Gradient Stop Verloopstop selecteren + Create Graph Diagram maken + Graph Data Diagramgegevens + Graph Type Diagramtype + Vector Halftone Vectorhalftoon + spot spot + Text Tekst + Artboard Options Tekenbordopties + Artboard Options… Tekenbordopties… + Collect in New Layer In nieuwe laag verzamelen + Delete Artboard Tekenbord verwijderen + Delete Selection Selectie verwijderen + Duplicate Selection Selectie dupliceren + Highlight Rows Rijen markeren + Layer Options Laagopties + Layer Options… Laagopties… + Make/Release Clipping Mask Knipmasker maken/vrijgeven + Move Rows Rijen verplaatsen + New Artboard Nieuw tekenbord + New Layer… Nieuwe laag… + New Sublayer… Nieuwe sublaag… + Paste Remembers Layers Plakken herinnert zich lagen + Reorder Opnieuw ordenen + Select Art Kunst selecteren + Set Current Layer Huidige laag instellen + Flatten Artwork Kunst afvlakken + Locate Object Object lokaliseren + Lock Others Overigen vergrendelen + Merge Selected Selectie samenvoegen + Outline Others Overigen omlijnen + Preview All Layers Alle lagen in voorbeeldweergave + Release to Layers (Build) Vrijgeven aan lagen (opbouwen) + Release to Layers (Sequence) Vrijgeven aan lagen (sequentie) + Reverse Order Volgorde omkeren + Show All Layers Alle lagen weergeven + Unlock All Layers Alle lagen ontgrendelen + English: USA Engels: VS + Move Verplaatsen + Character Style Tekenstijl + Paragraph Style Alineastijl + Style Stijl + Create New Library Nieuwe bibliotheek maken + Add to Library Aan bibliotheek toevoegen + Delete Library Bibliotheek verwijderen + Delete Library Item Bibliotheekitem verwijderen + Import Library Bibliotheek importeren + Export Library Bibliotheek exporteren + New Library Group Nieuwe bibliotheekgroep + Rename Library Group Bibliotheekgroep hernoemen + Delete Library Group Bibliotheekgroep verwijderen + Move Library Item to Group Bibliotheekitem naar groep verplaatsen + Rename Library Bibliotheek hernoemen + Show Library Bibliotheek weergeven + Use Library Item Bibliotheekitem gebruiken + Graphic Illustratie + Check Links Koppelingen controleren + Embed Image Afbeelding insluiten + Go To Link Naar koppeling gaan + Link Info Koppelingsinfo + Placement Options Plaatsingsopties + Relink Opnieuw koppelen + Unembed Insluiten ongedaan maken + Add Mesh Line Maaslijn toevoegen + Add Spine Point Aspunt toevoegen + Blend Info Blend-info + Blend Options Blend-opties + Delete Mesh Point Maaspunt verwijderen + Delete Spine Point Aspunt verwijderen + Envelope Info Omtrekinfo + Envelope Options Omtrekopties + Expand Gradient Mesh Verloopmaas omzetten + Make Envelope Omtrek maken + Move Envelope Mesh Point Maaspunt van omtrek verplaatsen + Move Mesh Point Maaspunt verplaatsen + Move Spine Point Aspunt verplaatsen + Reshape Spine Segment Segment van de as opnieuw vormgeven + Reset with Mesh Opnieuw instellen met maas + Reset with Warp Opnieuw instellen met vervorming + Set Mesh Point Color Kleur van maaspunt instellen + Edit Opacity Mask Dekkingmasker bewerken + Opacity Mask Dekkingmasker + Stop Editing Opacity Mask Stoppen met bewerken van dekkingmasker + View Opacity Mask Dekkingmasker weergeven + New Opacity Masks Are Clipping Nieuwe dekkingmaskers zijn knipmaskers + New Opacity Masks Are Inverted Nieuwe dekkingmaskers zijn omgekeerd + Rasterize Rasteriseren + Transform Each Individueel transformeren + New Art Appearance Vormgeving van nieuwe kunst + New Art Has Basic Appearance Nieuwe kunst heeft basisvormgeving + Delete Preset Voorinstelling verwijderen + High (300 ppi) Hoog (300 ppi) + Medium (150 ppi) Gemiddeld (150 ppi) + New Document Presets Nieuwe documentvoorinstellingen + Phone 390×844 Telefoon 390×844 + Postcard Ansichtkaart + Grid by Row Raster op rij + Grid by Column Raster op kolom + Arrange by Row Ordenen op rij + Arrange by Column Ordenen op kolom + Save Preset Voorinstelling opslaan + Screen (72 ppi) Scherm (72 ppi) + Social Square Post 1080×1080 Vierkante socialmediapost 1080×1080 + Distribute Verdelen + Distribute Spacing Spatiëring verdelen + Enter Isolation Mode Isolatiemodus openen + Live Shape Properties Eigenschappen van live-vorm + Nudge Duwen + Object Properties Objecteigenschappen + Reflect Spiegelen + Rotate Roteren + Set Bounds Grenzen instellen + Shear Vervalsen + Disable Opacity Mask Dekkingmasker uitschakelen + Enable Opacity Mask Dekkingmasker inschakelen + Link Opacity Mask Dekkingmasker koppelen + Make Opacity Mask Dekkingmasker maken + Opacity Mask Info Info over dekkingmasker + Opacity Mask Options Dekkingmaskeropties + Page Isolated Blending Geïsoleerd mengen op pagina + Page Knockout Group Knockoutgroep op pagina + Release Opacity Mask Dekkingmasker vrijgeven + Transparency Info Transparantie-info + Unlink Opacity Mask Dekkingmasker ontkoppelen + Overprint Overprint + Package Verpakken + Apply Last Color Laatste kleur toepassen + Apply Last Gradient Laatste verloop toepassen + Complement Complement + Default Fill and Stroke Standaard vulling en streek + Fill Vulling + Fill and Stroke Vulling en streek + Invert Omkeren + Recent Colors Recente kleuren + Swap Fill and Stroke Vulling en streek omwisselen + Toggle Fill/Stroke Focus Focus vulling/streek wisselen + Character / Paragraph Teken / alinea + Copy Artboard Tekenbord kopiëren + Cut Artboard Tekenbord knippen + Move Artboard Up/Down Tekenbord omhoog/omlaag verplaatsen + Add Anchor (Pen) Anker toevoegen (Pen) + Add Anchor Point Ankerpunt toevoegen + Average Gemiddelde + Close Path Pad sluiten + Convert Anchor Points Ankerpunten omzetten + Cut Path Pad knippen + Delete Anchor Points Ankerpunten verwijderen + Move Anchors Ankers verplaatsen + Move Direction Handle Richtingsgreep verplaatsen + Reshape Opnieuw vormgeven + Set Anchors Ankers instellen + Minus Front Min vooraan + Offset Path Pad offsetten + Simplify Vereenvoudigen + Split Into Grid Splitsen in raster + Total: Totaal: + Add Guides Hulplijnen toevoegen + Unite Verenigen + Delete Pattern Patroon verwijderen + Done Gereed + Expand Repeat Herhaling omzetten + List Patterns Patronen weergeven + Repeat Options Herhalingsopties + Save a Copy Kopie opslaan + Transform Patterns Patronen transformeren + Delete PDF Preset PDF-voorinstelling verwijderen + Export PDF Presets PDF-voorinstellingen exporteren + Import PDF Presets PDF-voorinstellingen importeren + PDF Preset PDF-voorinstelling + PDF Presets PDF-voorinstellingen + Save PDF Preset PDF-voorinstelling opslaan + Define Perspective Grid Perspectiefraster definiëren + Delete Perspective Grid Preset Voorinstelling voor perspectiefraster verwijderen + Export Perspective Grid Presets Voorinstellingen voor perspectiefraster exporteren + Import Perspective Grid Presets Voorinstellingen voor perspectiefraster importeren + Perspective Grid Options Perspectiefrasteropties + Perspective Grid Preset Voorinstelling voor perspectiefraster + Perspective Grid Presets Voorinstellingen voor perspectiefraster + Save Perspective Grid Preset Voorinstelling voor perspectiefraster opslaan + Image Info Afbeeldingsinfo + Load Place Cursor Plaatsingscursor laden + Placed File Info Info over geplaatst bestand + Install Plug-in Plug-in installeren + List Plug-ins Plug-ins weergeven + Plug-in Info Plug-in-info + Remove Plug-in Plug-in verwijderen + Run Plug-in Plug-in uitvoeren + Smooth Gladmaken + Corner Hoek + White Wit + 30° Angles Hoeken van 30° + 45° Angles Hoeken van 45° + 60° Angles Hoeken van 60° + 90° & 45° & 30° Angles Hoeken van 90° & 45° & 30° + 90° & 45° Angles Hoeken van 90° & 45° + 90° Angles Hoeken van 90° + Additional Plug-ins Folder Extra map met plug-ins + Additional Fonts Folder Extra map met lettertypen + Alignment Guides Uitlijnhulplijnen + Anchor Points, Handle, and Bounding Box Display Weergave van ankerpunten, greep en begrenzingskader + Anchor/Path Labels Anker-/padlabels + Angles Hoeken + Animated Zoom Geanimeerd zoomen + Anti-aliased Artwork Antialiasing voor kunst + Appearance Panel Vormgevingspaneel + Ask When Modified Vragen bij wijziging + Auto-Collapse Iconic Panels Pictogrampanelen automatisch samenvouwen + Show Tool Group Labels Labels voor gereedschapgroepen weergeven + Automatically Automatisch + Automatically Save Recovery Data Herstelgegevens automatisch opslaan + Baseline Shift Basislijnverschuiving + Brightness Helderheid + Canvas Color Canvaskleur + Centimeters Centimeter + Command Click to Select Objects Behind Command-klik om objecten erachter te selecteren + Constrain Angle Vastleghoek + Constrain Width and Height Proportions Breedte- en hoogteverhouding vastleggen + Construction Guides Bouwhulplijnen + Curvature Tool Krommingsgereedschap + Data Recovery Gegevensherstel + Default Language Standaardtaal + Disable Auto Add/Delete Automatisch toevoegen/verwijderen uitschakelen + Display All Blacks Accurately Alle zwarttinten nauwkeurig weergeven + Display All Blacks as Rich Black Alle zwarttinten weergeven als rijk zwart + Display Bitmaps as Anti-aliased Images in Pixel Preview Bitmaps in pixelvoorbeeld weergeven als afbeeldingen met antialiasing + Display Options Weergaveopties + Display Print Size at 100% Zoom Afdrukgrootte weergeven bij 100% zoom + Double Click To Isolate Dubbelklikken om te isoleren + Dutch Nederlands + East Asian Type Oost-Aziatische tekst + Enable Background Export Achtergrondexport inschakelen + Enable Background Save Achtergrondopslag inschakelen + Enable in-menu font previews Lettertypevoorbeelden in het menu inschakelen + Enable Missing Glyph Protection Beveiliging tegen ontbrekende glyphs inschakelen + Enable Rubber Band for Rubberband inschakelen voor + Enable Touch Gestures Touchgebaren inschakelen + Enable Touch Workspace Touch-werkruimte inschakelen + English: UK Engels: VK + Every (minutes) Elke (minuten) + Exceptions (comma separated) Uitzonderingen (gescheiden door komma's) + Feet Voet + Files Bestanden + Fill New Type Objects With Placeholder Text Nieuwe tekstobjecten met plaatshoudertekst vullen + Folder Map + Font Preview Size Grootte van lettertypevoorbeelden + Get Preferences Voorkeuren ophalen + GPU Performance GPU-prestaties + Graphics Processor Grafische processor + Gridline every Rasterlijn elke + Grids In Back Rasters op de achtergrond + Handles Grepen + Hide Corner Widget for angles greater than Hoekwidget verbergen voor hoeken groter dan + High Performance (discrete) Hoge prestaties (discrete) + Highlight Alternate Glyphs Alternatieve glyphs markeren + Highlight anchors on mouse over Ankerpunten markeren bij aanwijzen met de muis + History States Historiestatussen + Hollow Hol + Identify Objects By Objecten identificeren op + Include SVG Code SVG-code opnemen + Italian Italiaans + Keyboard Increment Toetsenbordincrement + Language Options Taalopties + Large Groot + Large Tabs Grote tabbladen + Legacy vector clipboard Verouderd vectorklembord + Legacy vector clipboard (no transparency) Verouderd vectorklembord (geen transparantie) + Line Color Lijnkleur + Lines Lijnen + List Preferences Voorkeuren weergeven + Manually Handmatig + Mark Older Files as [Converted] When Opened Oudere bestanden bij het openen markeren als [geconverteerd] + Match User Interface Brightness Aanpassen aan helderheid van gebruikersinterface + Light Gray Lichtgrijs + Medium Gray Middelgrijs + Dark Gray Donkergrijs + Measurement Labels Maatlabels + Meters Meter + Move Locked and Hidden Artwork with Artboard Vergrendelde en verborgen kunst met tekenbord verplaatsen + Number of Recent Files to Display Aantal weer te geven recente bestanden + Number of Recent Fonts Aantal recente lettertypen + Numbers Without Units Are Points Getallen zonder eenheid zijn punten + Object Highlighting Objectmarkering + Object Name Objectnaam + Object Selection by Path Only Objectselectie alleen via pad + On Copy Bij kopiëren + On Paste Bij plakken + On Screen Op scherm + Open Documents As Tabs Documenten openen als tabbladen + Options Opties + Output All Blacks Accurately Alle zwarttinten nauwkeurig uitvoeren + Output All Blacks as Rich Black Alle zwarttinten uitvoeren als rijk zwart + Override Character Color Tekenkleur overschrijven + Paste Offset Plakoffset + Pattern Editing Patroonbewerking + Pen Tool Pen-gereedschap + Picas Pica's + Portuguese Portugees + Preserve Appearance and Overprints Vormgeving en overprint behouden + Preserve Paths Paden behouden + Primary Primair + Printing / Exporting Afdrukken / exporteren + Real-time Drawing and Editing Tekenen en bewerken in realtime + Render Threads (-1 = Automatic) Renderthreads (-1 = automatisch) + Reset Preferences Voorkeuren herstellen + Scale Corners Hoeken schalen + Scale Cursor Proportional to UI Cursor proportioneel schalen met UI + Scale Strokes & Effects Streken & effecten schalen + Scratch Disks Scratchschijven + Secondary Secundair + Select Same Tint % Zelfde tint % selecteren + Set Preference Voorkeur instellen + Show East Asian Options Oost-Aziatische opties weergeven + Show Font Names in English Lettertypenamen in het Engels weergeven + Show handles when multiple anchors are selected Grepen weergeven wanneer meerdere ankers zijn geselecteerd + Show Indic Options Indische opties weergeven + Show Pixel Grid (Above 600% Zoom) Pixelraster weergeven (boven 600% zoom) + Show Slice Numbers Slicenummers weergeven + Show The Home Screen When No Documents Are Open Startscherm tonen als er geen documenten geopend zijn + Show Tool Tips Tooltips tonen + Size/Leading Tekengrootte/Regelafstand + Snapping Tolerance Snapping-tolerantie + Spacing Guides Hulplijnen voor tussenruimte + Spanish Spaans + Subdivisions Onderverdelingen + Templates Folder Sjablonenmap + Tile Edge Color Randkleur van tegels + Tolerance Tolerantie + Tracking Tracking + Transform Panel Transformatiepaneel + Transform Pattern Tiles Patroontegels transformeren + Transform Tools Transformatiegereedschappen + Turn off Data Recovery for complex documents Schakel gegevensherstel uit voor complexe documenten + Type Object Selection by Path Only Tekstobjecten alleen via hun pad selecteren + UI Scaling UI-schaling + Use Compression Compressie gebruiken + Use Japanese Crop Marks Japanse aftekenlijnen gebruiken + Use Low Resolution Proxy for Linked EPS Proxy met lage resolutie gebruiken voor gekoppelde EPS + Use Precise Cursors Nauwkeurige cursors gebruiken + Use Preview Bounds Voorvertoningsgrenzen gebruiken + When pasting text Bij het plakken van tekst + XML ID XML-id + Yards Yards + Zoom to Selection Inzoomen op selectie + Zoom with Mouse Wheel Zoomen met muiswiel + Scrub Numeric Fields by Dragging Numerieke velden aanpassen door te slepen + Linux Display Server Linux-weergaveserver + Print Preview Afdrukvoorbeeld + Print Setup Afdrukinstellingen + Bitmap Bitmap + Delete Print Preset Afdrukvoorinstelling verwijderen + Export Print Presets Afdrukvoorinstellingen exporteren + Import Print Presets Afdrukvoorinstellingen importeren + Print Preset Afdrukvoorinstelling + Print Presets Afdrukvoorinstellingen + Save Print Preset Afdrukvoorinstelling opslaan + Print Tiling Pages Pagina's tegelgewijs afdrukken + Document Raster Effects Settings Instellingen voor rastereffecten in document + Artwork Colors Kleuren in het artwork + Randomize Colors Kleuren willekeurig maken + Recolor Artwork Artwork opnieuw kleuren + Reduce Colors Kleuren verminderen + Discard Recovered Documents Teruggevonden documenten negeren + Recovery Data Herstelgegevens + Restore Recovered Documents Teruggevonden documenten herstellen + Save Recovery Data Herstelgegevens opslaan + Untitled Naamloos + Add to Selection Aan selectie toevoegen + Select Anchors Ankers selecteren + Select Segments Segmenten selecteren + Select Objects Objecten selecteren + Toggle Selection Selectie wisselen + Create Slice Slice maken + Delete Slices Slices verwijderen + Divide Slices Slices verdelen + List Slices Slices weergeven + Move Slices Slices verplaatsen + Select Slices Slices selecteren + Set Slice Rectangle Slice-rechthoek instellen + Slice Options Slice-opties + Add to Profiles Aan profielen toevoegen + Delete Profile Profiel verwijderen + Reset Profiles Profielen opnieuw instellen + Stroke Options Lijnopties + Width Profiles Breedteprofielen + Apply Graphic Style Grafische stijl toepassen + Break Link to Graphic Style Koppeling met grafische stijl verbreken + Delete Graphic Style Grafische stijl verwijderen + Duplicate Graphic Style Grafische stijl dupliceren + Graphic Style Options Opties voor grafische stijl + Graphic Styles Options Opties voor grafische stijlen + Default Graphic Style Standaard grafische stijl + Black Outline Zwarte omlijning + Heavy Ink Zware inkt + Sunshine Zonneschijn + List Graphic Styles Grafische stijlen weergeven + Merge Graphic Styles Grafische stijlen samenvoegen + Move Graphic Style Grafische stijl verplaatsen + New Graphic Style Nieuwe grafische stijl + Redefine Graphic Style Grafische stijl opnieuw definiëren + Rename Graphic Style Grafische stijl hernoemen + Add to Graphic Styles Aan grafische stijlen toevoegen + Graphic Style Library Bibliotheek met grafische stijlen + Add Used Colors Gebruikte kleuren toevoegen + Delete Swatch Kleurstaal verwijderen + Duplicate Swatch Kleurstaal dupliceren + Merge Swatches Kleurstalen samenvoegen + Move Swatch Kleurstaal verplaatsen + New Color Group Nieuwe kleurgroep + New Swatch Nieuw kleurstaal + Select All Unused Alle ongebruikte selecteren + Sort by Kind Sorteren op soort + Sort by Name Sorteren op naam + Spot Colors Spotkleuren + Spot Colors (0) Spotkleuren (0) + Swatch Options Kleurstaalopties + Ungroup Color Group Kleurgroep opheffen + Copy to User Defined Kopiëren naar Gebruikergedefinieerd + Swatch Library Bibliotheek met kleurstalen + Clear All Tabs Alle tabs wissen + Set Tab Stops Tabstops instellen + Tab Stops Tabstops + Flare Flare + Rays Stralen + Rectangle Rechthoek + Paintbrush Penseel + Pen Pen + Small Klein + Black and White Logo Zwart-witlogo + High Fidelity Photo Foto met hoge getrouwheid + Low Fidelity Photo Foto met lage getrouwheid + 3 Colors 3 kleuren + 6 Colors 6 kleuren + Shades of Gray Grijstinten + Sketched Art Geschetst artwork + Silhouettes Silhouetten + Line Art Lijntekening + Technical Drawing Technische tekening + Flat Logo Plat logo + Keep Formatting Opmaak behouden + Keep Plain Text Platte tekst behouden + Clip Clip + PDF PDF + no document geen document + Blue Blauw + Brown Bruin + Gray Grijs + Orange Oranje + Pink Roze + Purple Paars + Custom Aangepast + Document Setup Documentinstellingen + French Frans + German Duits + Inches Inch + Japanese Japans + Millimeters Millimeter + Points Punten +effect Points Punten + Adjust Colors Kleuren aanpassen + Place Plaatsen + Discard Empty Type Lege tekstobjecten negeren + Copyright Copyright + Other Overig + Print Afdrukken + Solid Effen + Difference Verschil + Flatten Transparency Transparantie afvlakken + Fonts Lettertypen + Off Uit + above boven + Square Vierkant + Update Links Koppelingen bijwerken + Liquify Vloeibaar maken + Corner Radius Hoekradius + Move Guide Hulplijn verplaatsen +axis Both Beide + Letter Letter + Portrait Staand + Recent Recent + Saved Opgeslagen + Web 1920×1080 Web 1920×1080 + Yellow Geel + Auto Automatisch + Center Midden + Transform in Perspective In perspectief transformeren + Grayscale Grijswaarden + Embed Insluiten + Star Ster + Power Saving (integrated) Energiebesparend (geïntegreerd) + Target Doel + X11 (XWayland) X11 (XWayland) + Print as Bitmap Afdrukken als bitmap + Draft Concept + Default Standaard + [VectorCraft Default] [VectorCraft-standaard] + Magenta Magenta + Page Pagina + Print Tiling Tegelgewijs afdrukken + Edit Color Group Kleurgroep bewerken + Marks Markeringen + Color Mode Kleurmodus + Templates Sjablonen + Halo Halo + Multiply Vermenigvuldigen + Red Rood + Grays Grijstinten + Color Group Kleurgroep + Library Bibliotheek + English English + List Folder Map weergeven + Area Type Options Gebiedstekstopties + Reshape Type Area Tekstgebied opnieuw vormgeven + Scale Schaal + Feet & Inches Voet & inch + Pixels Pixels + Pixel Pixel + More Meer + Show Tonen + Copy Width Point Breedtepunt kopiëren + Duplicate Artboard Tekengebied dupliceren + Area / Path Type Gebieds- / padtekst + Decrease Font Size Tekengrootte verkleinen + Increase Font Size Tekengrootte vergroten + Step Type Staptype + Get Text Range Tekstbereik ophalen + Punctuation Interpunctie + [Normal Character Style] [Normale tekenstijl] + Text Wrap Options Tekstomloopopties + Threads Tekstdraden + Orientation Oriëntatie + Set Text Tekst instellen + Insert Character Teken invoegen + Replace Vervangen + Type on a Path Options Opties voor tekst op pad + Highlight Variable Variabele markeren + New Variable Nieuwe variabele + List Variables Variabelen weergeven + Bind Variable Variabele binden + New Data Set Nieuwe gegevensset + List Data Sets Gegevenssets weergeven + Select Data Set Gegevensset selecteren + Edit Data Set Gegevensset bewerken + Visibility Zichtbaarheid + New View Nieuwe weergave + Saved Views Opgeslagen weergaven + Magic Wand Options Opties voor toverstaf + Magic Wand Settings Instellingen voor toverstaf + Delete Save for Web Preset Voorinstelling voor Opslaan voor web verwijderen + Save for Web Opslaan voor web + Save for Web Presets Voorinstellingen voor Opslaan voor web + GIF, 128 colours GIF, 128 kleuren + Save for Web Preview Voorvertoning Opslaan voor web + Save for Web Settings Instellingen Opslaan voor web + Save Save for Web Preset Voorinstelling Opslaan voor web opslaan + Eyedropper Options Opties voor pipet + Free Distort Vrij vervormen + Move Artboard Tekengebied verplaatsen + Sample Color Kleur bemonsteren + Angle Hoek + Black Zwart + Bottom Onder + Bounds Grenzen + Colors Kleuren + Cyan Cyaan + Discard Negeren + Document Document + Encoding Codering + Frequency Frequentie + Height Hoogte + Image Afbeelding + Ink Inkt + Lab Lab + Medium Gemiddeld + Name Naam + None Geen + Normal Normaal + Pages Pagina's + Regular Standaard + Source Sans 3 Source Sans 3 + Template Sjabloon + Top Boven + Width Breedte + : : +effect Regular Regelmatig + Saving Poster.vectorcraft Poster.vectorcraft wordt opgeslagen + Community Community + Hide Bar Balk verbergen + More Options Meer opties + Show Properties Panel Eigenschappenpaneel tonen + Isolate Isoleren + Lock (⌘2) Vergrendelen (⌘2) + New file Nieuw bestand + Open Openen + Quickly start a new file Snel een nieuw bestand starten + Recent Files Recente bestanden + Vector illustration — fast, open, scriptable. Vectorillustratie — snel, open, scriptbaar. + Welcome to VectorCraft Welkom bij VectorCraft + ArtCraft website Website van ArtCraft + Discord Discord + Join our Discord Word lid van onze Discord + Join the ArtCraft community on Discord (discord.gg/artcraft) Word lid van de ArtCraft-community op Discord (discord.gg/artcraft) + Source code on GitHub Broncode op GitHub + All Tools Alle gereedschappen + Draw Tekenen + Reset Opnieuw instellen + — missing — ontbreekt + (closest) (dichtstbijzijnde) + Change Wijzigen + Change All Alles wijzigen + Find Zoeken + Find Font Lettertype zoeken + Fonts in Document: {count} Lettertypen in document: {count} + In the selected objects In de geselecteerde objecten + Find in Folder… Zoeken in map… + Replace With Font Vervangen door lettertype + — substituted by {style} — vervangen door {style} + — {n} characters from another font — {n} tekens van een ander lettertype + Drag to float the panel group Sleep om de paneelgroep te laten zweven + Put back in the dock Terugzetten in het dock + All Scripts Alle scripts + Any Kind Elke soort + Decorative Decoratief + Favorites Favorieten + Latin Latijns + Thai Thais + Monospaced Monospace + No matching fonts Geen overeenkomende lettertypen + Rounded Afgerond + Sans Serif / Gothic Sans serif / Gothic + Script / Brush Script / penseel + Serif / Mincho Serif / Mincho + Changes made since it was last saved will be lost. Wijzigingen die zijn aangebracht sinds de laatste keer opslaan, gaan verloren. + Can't open “{name}”. Kan „{name}” niet openen. + Can't save “{name}”. Kan „{name}” niet opslaan. + Can't export “{name}”. Kan „{name}” niet exporteren. + Downloaded {what} {what} gedownload + Exported {count} file(s) to {folder} {count} bestand(en) geëxporteerd naar {folder} + Exported {what} {what} geëxporteerd + Exported {what} with {} note(s): {first} {what} geëxporteerd met {} opmerking(en): {first} + Exporting {} {} wordt geëxporteerd + Imported {what}: {} {what} geïmporteerd: {} + Installed plug-in {} Plug-in {} geïnstalleerd + Opened with {} note(s): {} Geopend met {} opmerking(en): {} + Revert to the saved version of “{name}”? Teruggaan naar de opgeslagen versie van „{name}”? + Replace “{name}”, the file this document was opened from? Vervang „{name}”, het bestand waarvan dit document is geopend? + Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Bij het openen is wat VectorCraft nog niet kan lezen ({what}) weggelaten, dus als je het vervangt, raak je dat definitief kwijt. Sla het onder een andere naam op om het te bewaren. + Saved {path} {path} opgeslagen + Saved {path} with {} note(s): {first} {path} opgeslagen met {} opmerking(en): {first} + Saving Bezig met opslaan + Saving {} {} wordt opgeslagen + Search commands and tools… Opdrachten en gereedschappen zoeken… + Dashed Line Streepjeslijn + Panel menu Paneelmenu + Unembed… Insluiten ongedaan maken… + Applies the next time VectorCraft starts. Wordt toegepast de volgende keer dat VectorCraft start. + Enable Contextual Task Bar Contextuele taakbalk inschakelen + Next Volgende + OK OK + Preferences Voorkeuren + Previous Vorige + Restore every preference to its default (applied on OK) Elke voorkeur terugzetten naar de standaard (toegepast bij OK) + Smaller ↔ Larger Kleiner ↔ Groter + Smart Guides (View → Smart Guides) Slimme hulplijnen (Beeld → Slimme hulplijnen) + Couldn't save recovery data: {e} Kon herstelgegevens niet opslaan: {e} + Discarded {} recovered document(s) {} teruggevonden document(en) genegeerd + Restored {} recovered document(s): save them to keep them {} teruggevonden document(en) hersteld: sla ze op om ze te behouden + Saved recovery data for {title} Herstelgegevens voor {title} opgeslagen + Saving recovery data for {} Herstelgegevens voor {} worden opgeslagen + Alt Alt + Backspace Backspace + Cmd Cmd + Ctrl Ctrl + Shift Shift + Couldn't copy to the system clipboard: {e} Kon niet naar het systeemklembord kopiëren: {e} + Couldn't paste from the clipboard: {e} Kon niet vanaf het klembord plakken: {e} + Channel Kanaal + Circle Cirkel + Clip to Art Clippen op artwork + Colorize Inkleuren + Gamma Gamma + Hue Tint + Input Black Invoer zwart + Keep Original Origineel behouden + Lightness Helderheid + Mono Mono + Output White Uitvoer wit + Saturation Verzadiging + Screens Rasters + Add Swatches Kleurstalen toevoegen + Apply to All Toepassen op alles + Swatch Conflict Conflict met kleurstalen + Axis: As: + Free Vrij + Keep: Behouden: + Search Zoeken + Classic Defaults Klassieke standaardwaarden + Uniform Uniform + Include Blacks with CMY: Zwart bij CMY opnemen: + Recolor Opnieuw kleuren + Swatch Tiles Kleurstaaltegels + Baseline Basislijn + Flip: Spiegelen: + Spacing: Tussenruimte: + Output Uitvoer + Export… Exporteren… + Import… Importeren… + Load Profile… Profiel laden… + Embedded Ingesloten + Image Tracing Beeldtracering + Linked File Gekoppeld bestand + Preset: Voorinstelling: + View: Weergave: + Tracing Result Traceringresultaat + Outlines with Source Image Contouren met bronafbeelding + SVG Options SVG-opties + Maximize Maximaliseren + Restore Herstellen + Align Objects: Objecten uitlijnen: + Default Fill and Stroke (D) Standaard vulling en lijn (D) + Fill (X), double-click for the Color Picker Vulling (X), dubbelklik voor de kleurenkiezer + Opacity: Dekking: + Shape Modes: Vormmodi: + Stroke (X), double-click for the Color Picker Lijn (X), dubbelklik voor de kleurenkiezer + Stroke: Lijn: + Swap Fill and Stroke (Shift+X) Vulling en lijn omwisselen (Shift+X) + Manage Workspaces Werkruimten beheren + Name: Naam: + New Nieuw + New Workspace Nieuwe werkruimte + Rename Hernoemen + Save the current layout under this name De huidige indeling onder deze naam opslaan + Saves the current bars, toolbar and panel layout. Slaat de huidige balken, werkbalk en paneelindeling op. + Workspace {i} Werkruimte {i} + Glowing Edges Gloeiende randen + Edge Width Randbreedte + Edge Brightness Randhelderheid + Smoothness Gladheid + Color Halftone Kleurhalftoon + Mezzotint Mezzotint + Pointillize Pointilleren + Max. Radius Max. straal + Channel 1 Kanaal 1 + Channel 2 Kanaal 2 + Channel 3 Kanaal 3 + Channel 4 Kanaal 4 + Cell Size Celgrootte + Radial Blur Radiale vervaging + Smart Blur Slim vervagen + Unsharp Mask Onscherp maskeren + Gaussian Blur Gaussiaanse vervaging + Method Methode + Quality Kwaliteit + Radius Straal + Apply Character Style Tekenstijl toepassen + Apply Paragraph Style Alineastijl toepassen + Character Style Options Tekenstijlopties + Flip Spiegelen + Couldn't {} {name}: {e} Kon {name} niet {}: {e} + Create PDF layers from top-level layers PDF-lagen maken van lagen op het hoogste niveau + Delete Character Style Tekenstijl verwijderen + Delete Layer Laag verwijderen + Delete Paragraph Style Alineastijl verwijderen + Duplicate Character Style Tekenstijl dupliceren + Duplicate Layer Laag dupliceren + Duplicate Paragraph Style Alineastijl dupliceren + Spacing Spatiëring + Mojikumi Mojikumi + N pt per N pt per + New Character Style Nieuwe tekenstijl + New Paragraph Style Nieuwe alineastijl + Paragraph Style Options Alineastijlopties + Redefine Character Style Tekenstijl opnieuw definiëren + Redefine Paragraph Style Alineastijl opnieuw definiëren + Rename Character Style Tekenstijl hernoemen + Rename Paragraph Style Alineastijl hernoemen + Select All Art on Layer Alle artwork op de laag selecteren + Settings… Instellingen… + Joins Aansluitingen + Joins: Aansluitingen: + Distance Afstand + Distance: Afstand: + Gutter Goot + Gutter: Goot: + Length Lengte + Length: Lengte: + Points: Punten: + Scale X Schaal X + Scale X: Schaal X: + Scale Y Schaal Y + Scale Y: Schaal Y: + Shear: Scheefheid: + Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required. Sleep artwork, kleuren en tekststijlen hierheen om ze in meerdere documenten te hergebruiken. Bibliotheken worden op deze computer opgeslagen — geen account vereist. + De-Interlace De-interlacing + Eliminate Verwijderen + Create New Fields by Nieuwe velden maken door + Craquelure Craquelure + Grain Korrel + Mosaic Tiles Mozaïektegels + Patchwork Patchwork + Stained Glass Glas in lood + Texturizer Texturizer + Crack Spacing Scheurafstand + Crack Depth Scheurdiepte + Crack Brightness Scheurhelderheid + Grain Type Korreltype + Tile Size Tegelgrootte + Grout Width Voegbreedte + Lighten Grout Voeg lichter maken + Square Size Vierkantgrootte + Relief Reliëf + Border Thickness Randdikte + Light Intensity Lichtintensiteit + Light Direction Lichtrichting + Value Axis Waarde-as + Diffuse Glow Diffuse gloed + Glass Glas + Ocean Ripple Oceaanrimpeling + Graininess Korreligheid + Glow Amount Gloedsterkte + Clear Amount Mate van helderheid + Ripple Size Rimpelgrootte + Ripple Magnitude Rimpelsterkte + Accented Edges Geaccentueerde randen + Angled Strokes Schuine streken + Dark Strokes Donkere streken + Ink Outlines Inktcontouren + Spatter Spatten + Sprayed Strokes Gespoten streken + Sumi-e Sumi-e + Direction Balance Richtingbalans + Stroke Length Streeklengte + Sharpness Scherpte + Strength Sterkte + Balance Balans + Black Intensity Zwartintensiteit + White Intensity Witintensiteit + Dark Intensity Donkerintensiteit + Spray Radius Spuitradius + Stroke Direction Streekrichting + Stroke Width Streekbreedte + Stroke Pressure Streekdruk +imageTrace Outline Contour +@plural {n} item|{n} items {n} item|{n} items +@plural Color Table ({n} color)|Color Table ({n} colors) Kleurentabel ({n} kleur)|Kleurentabel ({n} kleuren) +@plural Delete this {n} graphic style?|Delete these {n} graphic styles? Deze {n} grafische stijl verwijderen?|Deze {n} grafische stijlen verwijderen? +@plural {n} Layer|{n} Layers {n} laag|{n} lagen +@plural {n} Object|{n} Objects {n} object|{n} objecten +@plural {n} area highlighted|{n} areas highlighted {n} gebied gemarkeerd|{n} gebieden gemarkeerd +@plural {n} color|{n} colors {n} kleur|{n} kleuren +@plural {n} fill|{n} fills {n} vulling|{n} vullingen +@plural {n} glyph|{n} glyphs {n} glyph|{n} glyphs +@plural {n} object highlighted|{n} objects highlighted {n} object gemarkeerd|{n} objecten gemarkeerd +@plural {n} star|{n} stars {n} ster|{n} sterren +@plural {n} step|{n} steps {n} stap|{n} stappen +@plural {n} stroke|{n} strokes {n} lijn|{n} lijnen +@msg no library `{id}` geen bibliotheek `{id}` +@msg select some type first selecteer eerst wat tekst +@msg {_1} has no {_2} style {_1} heeft geen {_2}stijl +@msg a layer couldn't be read een laag kon niet worden gelezen +@msg it has more than one artboard het heeft meer dan één tekengebied +@msg it has no page het heeft geen pagina +@msg it has too many text objects het heeft te veel tekstobjecten +@msg it has too much text het heeft te veel tekst +@msg its editing data doesn't say where its artboard is zijn bewerkingsgegevens zeggen niet waar zijn tekengebied is +@msg its editing data has no layers zijn bewerkingsgegevens hebben geen lagen +@msg its editing data is damaged zijn bewerkingsgegevens zijn beschadigd +@msg its editing data is for a different page zijn bewerkingsgegevens zijn voor een andere pagina +@msg its layers use {_1}, which aren't read yet zijn lagen gebruiken {_1}, die nog niet worden gelezen +@msg its page has text, and its layers have no text objects to put it in zijn pagina heeft tekst, en zijn lagen hebben geen tekstobjecten om die in onder te brengen +@msg its type is to come in as outlines, which only its page has zijn tekst komt als contouren binnen, die alleen zijn pagina heeft +@msg the art on them differs from the page's in {_1}% of it het artwork erop wijkt in {_1}% af van dat van de pagina +@msg the art on them differs from the page's in {_1}% of it: they come from its editing data all the same het artwork erop wijkt in {_1}% af van dat van de pagina: ze komen desondanks uit zijn bewerkingsgegevens +@msg `to` must be "corner" or "smooth" `to` moet "corner" of "smooth" zijn +@msg BMP files are RGB or grayscale: colorModel rgb or gray BMP-bestanden zijn RGB of grijswaarden: colorModel rgb of gray +@msg Blend colors: select at least three objects with filled colours Kleuren mengen: selecteer minimaal drie objecten met een vulling +@msg Combine Slices: the slices have no bounds Slices combineren: de slices hebben geen grenzen +@msg Convert to Artboards: selection has no area Converteren naar tekengebieden: de selectie heeft geen oppervlakte +@msg Convert to Shape: no rectangles or ellipses selected Converteren naar vorm: geen rechthoeken of ellipsen geselecteerd +@msg Copied the CSS of {rules} object(s) CSS van {rules} object(en) gekopieerd +@msg Copied the script report Scriptrapport gekopieerd +@msg Corners edits the corners of a path Hoeken bewerkt de hoeken van een pad +@msg Couldn't copy to the system clipboard: {e} Kon niet naar het systeemklembord kopiëren: {e} +@msg Couldn't open {name}: {e} Kon {name} niet openen: {e} +@msg Couldn't place {name}: the document it was dropped on was closed Kon {name} niet plaatsen: het document waarop het werd neergezet, is gesloten +@msg Couldn't read {name}: {e} Kon {name} niet lezen: {e} +@msg Couldn't save recovery data: {e} Kon herstelgegevens niet opslaan: {e} +@msg Create Outlines: select text objects Contouren maken: selecteer tekstobjecten +@msg Create from Guides: the document has no artboard Uit hulplijnen maken: het document heeft geen tekengebied +@msg Create from Guides: the guides make more than {MAX_PIECES} slices Uit hulplijnen maken: de hulplijnen maken meer dan {MAX_PIECES} slices +@msg Create from Selection: the selection has no area Uit selectie maken: de selectie heeft geen oppervlakte +@msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Gegevensherstel heeft nergens ruimte om kopieën te bewaren: stel Voorkeuren → Bestandsafhandeling → Gegevensherstel → Map (recoveryFolder) in +@msg direct-select anchor points to cut at (not the end points of open paths) selecteer met directe selectie ankerpunten om te knippen (niet de eindpunten van open paden) +@msg Divide Objects Below: select a path Objecten eronder verdelen: selecteer een pad +@msg Divide Objects Below: select exactly one path Objecten eronder verdelen: selecteer precies één pad +@msg Downloaded {_1} file(s) {_1} bestand(en) gedownload +@msg Downloaded {what} {what} gedownload +@msg Expand Appearance: the selection has a basic appearance Vormgeving uitbreiden: de selectie heeft een basisvormgeving +@msg Expand: no brush strokes selected Uitbreiden: geen penseelstreken geselecteerd +@msg Expand: no gradient fills Uitbreiden: geen verloopvullingen +@msg Expand: nothing to expand Uitbreiden: niets om uit te breiden +@msg Exported {count} file(s) to {folder} {count} bestand(en) geëxporteerd naar {folder} +@msg Exported {path} {path} geëxporteerd +@msg Flatten Transparency: select objects to flatten Transparantie afvlakken: selecteer objecten om af te vlakken +@msg Flatten Transparency: {e} Transparantie afvlakken: {e} +@msg Image Trace: the traced image is missing Beeldtracering: de getraceerde afbeelding ontbreekt +@msg Imported {what}: {_1} {what} geïmporteerd: {_1} +@msg Installed plug-in {_1} Plug-in {_1} geïnstalleerd +@msg Internal error: {msg} (please report this bug) Interne fout: {msg} (meld deze bug alsjeblieft) +@msg Live Paint Merge: select a Live Paint group and the paths to add Live Paint samenvoegen: selecteer een Live Paint-groep en de paden om toe te voegen +@msg Live Paint: select paths or compound paths Live Paint: selecteer paden of samengestelde paden +@msg Live Paint: these paths are too degenerate to paint Live Paint: deze paden zijn te gedegenereerd om te verven +@msg Make Guides: select paths Hulplijnen maken: selecteer paden +@msg No saved selections (Select → Save Selection…) Geen opgeslagen selecties (Selecteren → Selectie opslaan…) +@msg No saved views (View → New View…) Geen opgeslagen weergaven (Beeld → Nieuwe weergave…) +@msg Offset Path: select paths Pad verschuiven: selecteer paden +@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Pad verschuiven: de verschuiving laat van de geselecteerde paden niets over (een insprong groter dan de vormen) +@msg Opened with {_1} note(s): {_2} Geopend met {_1} opmerking(en): {_2} +@msg Opened {url} {url} geopend +@msg Outline Stroke: no stroked paths selected Lijn omzetten in contouren: geen paden met een lijn geselecteerd +@msg PDF settings are not an object PDF-instellingen zijn geen object +@msg PDF writer error: {0} PDF-schrijffout: {0} +@msg Packaged {n} file(s) in {folder}{note} {n} bestand(en) verpakt in {folder}{note} +@msg Packaged {n} file(s){note} {n} bestand(en) verpakt{note} +@msg Preview in Browser needs the desktop app (the web downloads files instead) Voorvertoning in browser heeft de desktop-app nodig (het web downloadt in plaats daarvan bestanden) +@msg Release Guides: no guides Hulplijnen vrijgeven: geen hulplijnen +@msg Release: no layer to put the rectangle in Vrijgeven: geen laag om de rechthoek in te plaatsen +@msg Replace Spine: select a blend and a path Ruggengraat vervangen: selecteer een mengsel en een pad +@msg select two or more objects to distribute selecteer twee of meer objecten om te verdelen +@msg select paths or anchor points selecteer paden of ankerpunten +@msg SVG parse error: {0} SVG-parsefout: {0} +@msg Save for Web: the image is empty Opslaan voor web: de afbeelding is leeg +@msg Save writes {_1}, not {_2}: use document.export for other formats Opslaan schrijft {_1}, niet {_2}: gebruik document.export voor andere indelingen +@msg Saved recovery data for {title} Herstelgegevens voor {title} opgeslagen +@msg Saved to User Defined: {_1} Opgeslagen in Gebruikergedefinieerd: {_1} +@msg Saved {path} {path} opgeslagen +@msg Select text or click into text with the Type tool to insert glyphs Selecteer tekst of klik met het tekstgereedschap in tekst om glyphs in te voegen +@msg Shape Builder: no region under the pointer Vormbouwer: geen gebied onder de aanwijzer +@msg Shape Builder: select paths or compound paths Vormbouwer: selecteer paden of samengestelde paden +@msg Show All Hidden Attributes: nothing is hidden Alle verborgen kenmerken tonen: er is niets verborgen +@msg Split Into Grid: select paths large enough for the grid In raster verdelen: selecteer paden die groot genoeg zijn voor het raster +@msg Targa files are RGB: colorModel rgb Targa-bestanden zijn RGB: colorModel rgb +@msg Text Wrap: select a wrap object Tekstomloop: selecteer een omloopobject +@msg `aspect` must be a number (%) `aspect` moet een getal zijn (%) +@msg `at` must be [x, y] `at` moet [x, y] zijn +@msg `color` must be a colour `color` moet een kleur zijn +@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` moet een opdracht zijn waarvan het menu-item een dialoogvenster opent (zie ui.menuDialog) +@msg `freeform.points` must be an array `freeform.points` moet een array zijn +@msg `freeform.lines` must be an array `freeform.lines` moet een array zijn +@msg `gradient` must be an object `gradient` moet een object zijn +@msg `library` is a library id or name (see {list}) `library` is een bibliotheek-id of -naam (zie {list}) +@msg `mode` applies to freeform gradients `mode` geldt voor verlopen met vrije vorm +@msg `sampleSize` must be 1, 3 or 5 `sampleSize` moet 1, 3 of 5 zijn +@msg `stops` must be an array `stops` moet een array zijn +@msg `svg` must be an object of SVG options (see document.formats) `svg` moet een object met SVG-opties zijn (zie document.formats) +@msg `{_1}` is no save command `{_1}` is geen opslagopdracht +@msg `{area}` is no recovery area name `{area}` is geen naam van een herstelgebied +@msg `{color}` is not a colour (#rrggbb or a preset name) `{color}` is geen kleur (#rrggbb of een voorinstellingsnaam) +@msg `{id}` has no envelope dialog `{id}` heeft geen omslagdialoogvenster +@msg `{keep}` is not a side of this axis `{keep}` is geen zijde van deze as +@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` moet `auto` zijn of een taalcode zoals `en` of `zh-hant` +@msg `{key}` must be a #rrggbb colour `{key}` moet een #rrggbb-kleur zijn +@msg `{key}` must be a number `{key}` moet een getal zijn +@msg `{key}` must be a string `{key}` moet een string zijn +@msg `{key}` must be a whole number `{key}` moet een geheel getal zijn +@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 `{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 +@msg `{name}` is a built-in workspace; choose another name `{name}` is een ingebouwde werkruimte; kies een andere naam +@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` is een proceskleur: tinten gelden voor globale en spotkleuren +@msg `{name}` is no recovery entry name `{name}` is geen naam van een herstelitem +@msg `{name}` isn't a colour swatch `{name}` is geen kleurstaal +@msg `{path}` must be a bool or an object of bools `{path}` moet een booleaanse waarde zijn of een object met booleaanse waarden +@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` is geen kleur (#rrggbb of een voorinstellingsnaam) +@msg `{tool}` isn't a Liquify tool `{tool}` is geen gereedschap voor Vloeibaar maken +@msg `{tool}` isn't a freehand tool `{tool}` is geen vrijhandgereedschap +@msg `{to}` is not an available name `{to}` is geen beschikbare naam +@msg a PostScript error ({name} in `{op}`) een PostScript-fout ({name} in `{op}`) +@msg a PostScript error ({name} in `{op}`), in {procs} een PostScript-fout ({name} in `{op}`), in {procs} +@msg a PostScript error ({name} in `{op}`: {at}) een PostScript-fout ({name} in `{op}`: {at}) +@msg a PostScript error ({name} in `{op}`: {at}), in {procs} een PostScript-fout ({name} in `{op}`: {at}), in {procs} +@msg a PostScript error ({name}) een PostScript-fout ({name}) +@msg a bitmap is empty een bitmap is leeg +@msg a bitmap is too large to read een bitmap is te groot om te lezen +@msg a colour index is 0 to {_1}, not {n} een kleurindex is 0 tot {_1}, niet {n} +@msg a colour is an index, #rrggbb or a preset name, not {v} een kleur is een index, #rrggbb of een voorinstellingsnaam, niet {v} +@msg a document needs at least one artboard een document heeft minstens één tekengebied nodig +@msg a document needs at least one layer een document heeft minstens één laag nodig +@msg a freeform gradient keeps at least one point een verloop met vrije vorm behoudt minstens één punt +@msg a gradient needs at least two stops een verloop heeft minstens twee stops nodig +@msg a graphic style named `{to}` already exists er bestaat al een grafische stijl met de naam `{to}` +@msg a page is missing er ontbreekt een pagina +@msg a page out of range een pagina buiten het bereik +@msg a page tree kid isn't a reference een kind van de paginaboom is geen verwijzing +@msg a page tree node has no kids een knooppunt van de paginaboom heeft geen kinderen +@msg a page's placement is not finite de plaatsing van een pagina is niet eindig +@msg a preset named `{_1}` exists er bestaat een voorinstelling met de naam `{_1}` +@msg a slice size must be a positive number of points een slicegrootte moet een positief aantal punten zijn +@msg a stream runs past the end een stream loopt voorbij het einde +@msg a stream's length is wrong de lengte van een stream is onjuist +@msg a stream's length isn't a number de lengte van een stream is geen getal +@msg add a format to export voeg een indeling toe om te exporteren +@msg already editing a pattern er wordt al een patroon bewerkt +@msg an object can't be read een object kan niet worden gelezen +@msg an object in perspective would leave the edited grid een object in perspectief zou buiten het bewerkte raster vallen +@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art of type +@msg artboard {0} does not exist tekengebied {0} bestaat niet +@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard bij deze schaal is de tekening {w} × {h} pt, meer dan het canvas kan bevatten: kies een kleinere schaal of pas de tekening aan op het tekengebied +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` as moet "vertical", "horizontal" of een hoek in graden zijn, niet `{v}` +@msg background {other}: transparent, white, black or a colour such as "#ff8800" achtergrond {other}: transparant, wit, zwart of een kleur zoals "#ff8800" +@msg bad color {v} onjuiste kleur {v} +@msg bad colour `{hex}` (#rrggbb) onjuiste kleur `{hex}` (#rrggbb) +@msg bad page tree onjuiste paginaboom +@msg bad shortcut onjuiste sneltoets +@msg bad stream length onjuiste streamlengte +@msg bad trailer onjuiste trailer +@msg balance must be between 0 and 100 balans moet tussen 0 en 100 liggen +@msg batch step {_1} (`{id}`) failed: {e} batchstap {_1} (`{id}`) is mislukt: {e} +@msg brightness must be dark|mediumDark|mediumLight|light helderheid moet dark|mediumDark|mediumLight|light zijn +@msg can't encode the document: {0} kan het document niet coderen: {0} +@msg can't parse shortcut `{c}` kan sneltoets `{c}` niet parseren +@msg can't parse shortcut `{s}` for `{k}` kan sneltoets `{s}` voor `{k}` niet parseren +@msg can't save version {0}: VectorCraft writes versions {MIN_VERSION} to {VERSION} kan versie {0} niet opslaan: VectorCraft schrijft versies {MIN_VERSION} tot {VERSION} +@msg can't write .{ext} images kan geen .{ext}-afbeeldingen schrijven +@msg cannot distort a zero-size bounding box kan een omtrekkader met afmetingen nul niet vervormen +@msg cannot read PDF: {0} kan PDF niet lezen: {0} +@msg cannot split an open path at its end point kan een open pad niet splitsen op zijn eindpunt +@msg closing needs three anchors voor sluiten zijn drie ankers nodig +@msg check an asset to export vink een asset aan om te exporteren +@msg choice must be "merge" or "add", not `{choice}` choice moet "merge" of "add" zijn, niet `{choice}` +@msg choose a direction to divide in kies een richting om in te verdelen +@msg choose a folder to export to kies een map om naar te exporteren +@msg choose a location for the package kies een locatie voor het pakket +@msg clipboard is empty het klembord is leeg +@msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` is geen kleur zoals "#ff8800" +@msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: maximaal {MAX_TABLE} kleuren +@msg command `{0}` is not available right now: {1} opdracht `{0}` is nu niet beschikbaar: {1} +@msg compound paths can only contain paths samengestelde paden kunnen alleen paden bevatten +@msg compressed (RLE) bitmaps can't be read yet gecomprimeerde (RLE) bitmaps kunnen nog niet gelezen worden +@msg damaged beschadigd +@msg decimals must be {_1}–{_2} (got {_3}) decimalen moeten {_1}–{_2} zijn (kreeg {_3}) +@msg divide into 1 to {MAX_PIECES} slices deel op in 1 tot {MAX_PIECES} slices +@msg effect {k} is `{_1}`, not `{id}` effect {k} is `{_1}`, niet `{id}` +@msg empty brush stroke lege penseelstreek +@msg empty cutter lege snijder +@msg empty path leeg pad +@msg file version {0} is newer than this VectorCraft supports ({VERSION}) bestandsversie {0} is nieuwer dan deze VectorCraft ondersteunt ({VERSION}) +@msg freeform point {i} needs `at` [x, y] and a valid `color` vrijvormpunt {i} heeft `at` [x, y] en een geldige `color` nodig +@msg freeform line {i} must be an array of point indices vrijvormlijn {i} moet een array van puntindexen zijn +@msg give both `start` and `end` [x, y] (or neither) geef zowel `start` als `end` [x, y] (of geen van beide) +@msg give corners of the path: anchors where its sides meet at an angle geef hoeken van het pad: ankers waar de zijden onder een hoek samenkomen +@msg give horizontal or vertical geef horizontaal of verticaal +@msg give the path `id` and the width point `index` geef het `id` van het pad en de `index` van het breedtepunt +@msg guides are locked de hulplijnen zijn vergrendeld +@msg image '{key}' has no pixels and was left out afbeelding '{key}' heeft geen pixels en is weggelaten +@msg image data `{_1}` is missing afbeeldingsgegevens `{_1}` ontbreken +@msg image would be too large; lower the resolution de afbeelding zou te groot worden; verlaag de resolutie +@msg index must be a number or first, previous, next or last index moet een getal zijn of first, previous, next of last +@msg index must be a number or first, previous, next or last, not `{other}` index moet een getal zijn of first, previous, next of last, niet `{other}` +@msg installing from a path is not available on the web; pass the module as `dataBase64` installeren vanaf een pad is niet beschikbaar op het web; geef de module door als `dataBase64` +@msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) interne fout in `{cmd}`: {msg} (het document is ongewijzigd gelaten; meld deze bug alsjeblieft) +@msg internal error: {msg} (please report this bug) interne fout: {msg} (meld deze bug alsjeblieft) +@msg invalid PDF setting: {0} ongeldige PDF-instelling: {0} +@msg invalid image data for `{0}` ongeldige afbeeldingsgegevens voor `{0}` +@msg invalid parameters for `{cmd}`: {msg} ongeldige parameters voor `{cmd}`: {msg} +@msg is not an image VectorCraft reads is geen afbeelding die VectorCraft leest +@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know het gebruikt `{at}`, die de PostScript-lezer van VectorCraft niet kent +@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know, in {procs} het gebruikt `{at}`, die de PostScript-lezer van VectorCraft niet kent, in {procs} +@msg its Windows metafile preview was opened instead, which may simplify the art zijn Windows-metabestandvoorbeeld is in plaats daarvan geopend, wat het artwork kan vereenvoudigen +@msg its preview was placed instead, at screen resolution zijn voorvertoning is in plaats daarvan geplaatst, op schermresolutie +@msg join needs open paths samenvoegen heeft open paden nodig +@msg lang must be one of {_1} lang moet een van {_1} zijn +@msg layers can't be wrap objects lagen kunnen geen omloopobjecten zijn +@msg missing `folder` `folder` ontbreekt +@msg missing `id` (see plugin.list) `id` ontbreekt (zie plugin.list) +@msg missing `name` `name` ontbreekt +@msg missing `tool` `tool` ontbreekt +@msg missing index index ontbreekt +@msg multiple windows land with M11.5 meerdere vensters komen binnen met M11.5 +@msg name the file geef het bestand een naam +@msg no CSS to copy: select objects with CSS of their own geen CSS om te kopiëren: selecteer objecten met eigen CSS +@msg no Live Paint edge near that point geen Live Paint-rand bij dat punt +@msg no Live Paint face at that point geen Live Paint-vlak bij dat punt +@msg no PNG encoder geen PNG-encoder +@msg no active document geen actief document +@msg no active perspective plane geen actief perspectiefvlak +@msg no anchor points selected geen ankerpunten geselecteerd +@msg no app to open files here hier is geen app om bestanden mee te openen +@msg no artboard geen tekengebied +@msg no artboard {i} (0-based; the document has {_1}) geen tekengebied {i} (vanaf 0; het document heeft er {_1}) +@msg no artboard {i} (0-based; the document has {count}) geen tekengebied {i} (vanaf 0; het document heeft er {count}) +@msg no artboards named geen benoemde tekengebieden +@msg no artwork geen artwork +@msg no blend options geen mengselopties +@msg no bounds geen grenzen +@msg no brush named `{name}` geen penseel met de naam `{name}` +@msg no brush selected geen penseel geselecteerd +@msg no catalog geen catalogus +@msg no changes since the last save geen wijzigingen sinds de laatste keer opslaan +@msg no clock to keep a heartbeat by geen klok om een hartslag bij te houden +@msg no colour group `{g}` geen kleurgroep `{g}` +@msg no cross-reference table geen kruisverwijzingstabel +@msg no current layer geen huidige laag +@msg no data geen gegevens +@msg no dialog open geen dialoogvenster geopend +@msg no document geen document +@msg no document open geen document geopend +@msg no effect applied yet nog geen effect toegepast +@msg no effect at index {k} geen effect op index {k} +@msg no end record geen eindrecord +@msg no file manager here hier is geen bestandsbeheerder +@msg no file reader geen bestandslezer +@msg no file system here hier is geen bestandssysteem +@msg no file writer geen bestandsschrijver +@msg no files to place geen bestanden om te plaatsen +@msg no format to export to geen indeling om naar te exporteren +@msg no free recovery area geen vrij herstelgebied +@msg no gradient stop or freeform point is selected geen verloopstop of vrijvormpunt geselecteerd +@msg no graphic style `{name}` geen grafische stijl `{name}` +@msg no graphic style `{n}` geen grafische stijl `{n}` +@msg no interaction in progress geen interactie gaande +@msg no key object geen sleutelobject +@msg no library `{key}` (see {list}) geen bibliotheek `{key}` (zie {list}) +@msg no native format geen eigen indeling +@msg no pages geen pagina's +@msg no pattern `{name}` geen patroon `{name}` +@msg no perspective grid preset named `{name}` geen perspectiefrastervoorinstelling met de naam `{name}` +@msg no plug-in {0} is installed er is geen plug-in {0} geïnstalleerd +@msg no plug-in {id} is installed er is geen plug-in {id} geïnstalleerd +@msg no point {i} (the gradient has {_1}) geen punt {i} (het verloop heeft {_1}) +@msg no preset editor geen voorinstellingseditor +@msg no previous transform geen eerdere transformatie +@msg no print preset named `{base}` geen afdrukvoorinstelling met de naam `{base}` +@msg no print preset named `{name}` geen afdrukvoorinstelling met de naam `{name}` +@msg no print service geen afdrukservice +@msg no revert command geen terugzetopdracht +@msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) geen opgeslagen PDF-voorinstelling met de naam `{name}` (ingebouwde voorinstellingen zijn alleen-lezen: maak een nieuwe op basis van een ingebouwde) +@msg no saved presets geen opgeslagen voorinstellingen +@msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) geen opgeslagen afdrukvoorinstelling met de naam `{name}` ({DEFAULT_PRESET} is beveiligd: maak een nieuwe op basis daarvan) +@msg no saved profiles geen opgeslagen profielen +@msg no saved selections geen opgeslagen selecties +@msg no saved themes geen opgeslagen thema's +@msg no segment {segment} on line {line} geen segment {segment} op lijn {line} +@msg no selected slice in the image: select slices, or save all of them geen geselecteerde slice in de afbeelding: selecteer slices of sla ze allemaal op +@msg no selection bounds geen begrenzing van de selectie +@msg no slice selected geen slice geselecteerd +@msg no such action actie bestaat niet +@msg no such anchor anker bestaat niet +@msg no such artboard tekengebied bestaat niet +@msg no such document document bestaat niet +@msg no such layer or object laag of object bestaat niet +@msg no such object {0} object {0} bestaat niet +@msg no such perspective grid preset perspectiefrastervoorinstelling bestaat niet +@msg no such recent file recent bestand bestaat niet +@msg no such recent font recent lettertype bestaat niet +@msg no such saved selection opgeslagen selectie bestaat niet +@msg no such segment segment bestaat niet +@msg no such subpath subpad bestaat niet +@msg no such user library gebruikersbibliotheek bestaat niet +@msg no such view weergave bestaat niet +@msg no such width point breedtepunt bestaat niet +@msg no swatch `{name}` geen staal `{name}` +@msg no swatch library `{l}` (see swatch.library.list) geen stalenbibliotheek `{l}` (zie swatch.library.list) +@msg no symbol instances selected geen symboolinstanties geselecteerd +@msg no target object has an opacity mask: select one or give `id` geen van de doelobjecten heeft een dekkingmasker: selecteer er een of geef `id` +@msg no threaded text selected geen gekoppelde tekst geselecteerd +@msg no user library folder here geen gebruikersbibliotheekmap hier +@msg no user workspace `{from}` geen gebruikerswerkruimte `{from}` +@msg node {_1} is not text knooppunt {_1} is geen tekst +@msg not a DXF drawing geen DXF-tekening +@msg not a VectorCraft file: {0} geen VectorCraft-bestand: {0} +@msg not a VectorCraft shortcut set (missing `overrides`) geen VectorCraft-sneltoetsenset (`overrides` ontbreekt) +@msg not a graphic style library (format `{_1}`) geen bibliotheek met grafische stijlen (indeling `{_1}`) +@msg not a stored central record geen opgeslagen centraal record +@msg not a valid plug-in module: {0} geen geldige plug-inmodule: {0} +@msg not an EMF or WMF file geen EMF- of WMF-bestand +@msg not found niet gevonden +@msg not in pattern editing mode niet in patroonbewerkingsmodus +@msg nothing encoded niets gecodeerd +@msg nothing selected niets geselecteerd +@msg nothing to align niets om uit te lijnen +@msg nothing to distort niets om te vervormen +@msg nothing to export: the document has no visible art (or turn on Use Artboards) niets om te exporteren: het document heeft geen zichtbare illustratie (of schakel Tekengebieden gebruiken in) +@msg nothing to preview niets om te bekijken +@msg nothing to print niets om af te drukken +@msg nothing to redo niets om opnieuw uit te voeren +@msg nothing to save: the document has no visible art niets om op te slaan: het document heeft geen zichtbare illustratie +@msg nothing to undo niets om ongedaan te maken +@msg nothing to warp niets om te buigen +@msg nothing to wrap niets om te laten omlopen +@msg object {0} cannot have children object {0} kan geen onderliggende objecten hebben +@msg object {id} is not a path object {id} is geen pad +@msg object {id} is not a selected top-level object object {id} is geen geselecteerd object op het hoogste niveau +@msg page size {w} × {h} paginagrootte {w} × {h} +@msg page {0} does not exist (the PDF has {1}) pagina {0} bestaat niet (de PDF heeft er {1}) +@msg paste unavailable plakken niet beschikbaar +@msg path is already closed pad is al gesloten +@msg paths contain invalid coordinates paden bevatten ongeldige coördinaten +@msg paths: give at least one file paden: geef minstens één bestand +@msg pattern missing patroon ontbreekt +@msg patterns nested too deeply are filled with mid-grey patronen die te diep genest zijn, worden gevuld met middelgrijs +@msg percent must be above 0 and at most 6400, not {p} procent moet boven 0 en maximaal 6400 zijn, niet {p} +@msg pick a colour, gradient or pattern to save as a swatch kies een kleur, verloop of patroon om als staal op te slaan +@msg plane must be left, right or ground vlak moet links, rechts of grond zijn +@msg plug-in ABI: {0} plug-in-ABI: {0} +@msg plug-in exceeded its {0} plug-in overschreed zijn {0} +@msg plug-in failed: {0} plug-in mislukt: {0} +@msg plug-in manifest: {0} plug-in-manifest: {0} +@msg plug-in output: {0} plug-in-uitvoer: {0} +@msg plug-in parameters: {0} plug-in-parameters: {0} +@msg plug-in trapped: {0} plug-in vastgelopen: {0} +@msg print settings are not an object afdrukinstellingen zijn geen object +@msg printing isn't available here afdrukken is hier niet beschikbaar +@msg range `{s}` is empty bereik `{s}` is leeg +@msg range `{s}`: `{part}` names no number bereik `{s}`: `{part}` benoemt geen getal +@msg range `{s}`: `{part}` runs backwards bereik `{s}`: `{part}` loopt achteruit +@msg range `{s}`: `{t}` is not a number from 1 to {count} bereik `{s}`: `{t}` is geen getal van 1 tot {count} +@msg recovery copy `{file}`: {e} herstelkopie `{file}`: {e} +@msg resolutions must be between 1 and 2400 ppi resoluties moeten tussen 1 en 2400 ppi liggen +@msg result would exceed the canvas (coordinates out of range) het resultaat zou het canvas overschrijden (coördinaten buiten bereik) +@msg objects can't be nested more than 32 deep objecten kunnen niet dieper dan 32 niveaus worden genest +@msg the SVG's elements are nested more than {0} deep de elementen van de SVG zijn dieper dan {0} niveaus genest +@msg scale must be a number from {_1} to {_2}, not {_3} schaal moet een getal van {_1} tot {_2} zijn, niet {_3} +@msg scale must be a number from {_1} to {_2}, not {s} schaal moet een getal van {_1} tot {_2} zijn, niet {s} +@msg select (or target) objects whose appearance isn't basic selecteer (of richt je op) objecten waarvan de vormgeving niet basis is +@msg select a Live Paint group selecteer een Live Paint-groep +@msg select a blend selecteer een vermenging +@msg select a clip group selecteer een knipgroep +@msg select a gradient mesh selecteer een verloopnet +@msg select a linked image: an embedded one has no file selecteer een gekoppelde afbeelding: een ingesloten afbeelding heeft geen bestand +@msg select a repeat selecteer een herhaling +@msg select a stroke with a variable width selecteer een lijn met een variabele breedte +@msg select a symbol instance selecteer een symboolinstantie +@msg select an Image Trace object selecteer een Image Trace-object +@msg could not decode image: {0} afbeelding kon niet worden gedecodeerd: {0} +@msg image is empty afbeelding is leeg +@msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) de afbeelding is {width} × {height} pixels, meer dan Image Trace aankan ({max} megapixels) +@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset deze tracering zou meer dan {max} ankerpunten opleveren, meer dan het document vloeiend kan tonen: verhoog Ruis, verlaag Paden of Kleuren, of kies een voorinstelling met een lagere getrouwheid +@msg select an envelope selecteer een omvorming +@msg select an image selecteer een afbeelding +@msg select an object in perspective selecteer een object in perspectief +@msg select artwork (or a colour group) to recolor selecteer een illustratie (of een kleurgroep) om te herkleuren +@msg select at least two objects selecteer minstens twee objecten +@msg select objects, not layers selecteer objecten, geen lagen +@msg select one path, or give its `id` selecteer één pad, of geef het `id` +@msg select paths to join selecteer paden om te verbinden +@msg select the art to repeat selecteer de illustratie die je wilt herhalen +@msg select the artwork to warp selecteer de illustratie die je wilt buigen +@msg select the objects to transform in perspective selecteer de objecten die je in perspectief wilt transformeren +@msg select two or more graphic styles to merge selecteer twee of meer grafische stijlen om samen te voegen +@msg select type in perspective selecteer tekst in perspectief +@msg selection has no bounds selectie heeft geen begrenzing +@msg shadings of an unknown type were left out shadings van een onbekend type zijn weggelaten +@msg shadings of more than 4096 patches or triangles were left out shadings met meer dan 4096 patches of driehoeken zijn weggelaten +@msg shadings whose functions are of an unknown type are filled with their middle colour shadings waarvan de functies van een onbekend type zijn, worden gevuld met hun middelste kleur +@msg shortcut for `{k}` must be a string sneltoets voor `{k}` moet een string zijn +@msg stop {i} needs `offset` stop {i} heeft `offset` nodig +@msg stop {i} needs a valid `color` stop {i} heeft een geldige `color` nodig +@msg stop {i}: `{k}` must be a number stop {i}: `{k}` moet een getal zijn +@msg the EMF header is damaged de EMF-header is beschadigd +@msg the PDF has no pages de PDF heeft geen pagina's +@msg the PDF is no longer loaded de PDF is niet langer geladen +@msg the PDF is password-protected: give its password de PDF is met een wachtwoord beveiligd: geef het wachtwoord op +@msg the PDF password is wrong het PDF-wachtwoord is onjuist +@msg the Save PDF dialog didn't open het dialoogvenster PDF opslaan is niet geopend +@msg the WMF header is damaged de WMF-header is beschadigd +@msg the art could not be sampled de illustratie kon niet worden bemonsterd +@msg the art has no bounds de illustratie heeft geen begrenzing +@msg the art is too light for any dots de illustratie is te licht voor stippen +@msg the artboard to fit must be a positive size up to {_1} pt het aan te passen tekengebied moet een positieve afmeting van maximaal {_1} pt zijn +@msg the background worker stopped het achtergrondproces is gestopt +@msg the compressed file unpacks to more than {_1} MB het gecomprimeerde bestand pakt uit tot meer dan {_1} MB +@msg the document has never been saved het document is nog nooit opgeslagen +@msg the document has no artboard het document heeft geen tekengebied +@msg the document has no artboards het document heeft geen tekengebieden +@msg the document has no patterns het document heeft geen patronen +@msg the document has no slices het document heeft geen slices +@msg the document has no symbols het document heeft geen symbolen +@msg the drawing has no layout `{_1}` (it has {_2}) de tekening heeft geen layout `{_1}` (hij heeft {_2}) +@msg the drawing is no longer loaded de tekening is niet langer geladen +@msg the drawing is too large to open de tekening is te groot om te openen +@msg the export region is empty het exportgebied is leeg +@msg the export region is not finite het exportgebied is niet eindig +@msg the export returned unreadable data de export leverde onleesbare gegevens op +@msg the export wrote no file de export heeft geen bestand geschreven +@msg the file holds only a placeholder page, not its art (it was saved without PDF compatibility): save it again with PDF compatibility on, or as PDF or SVG het bestand bevat alleen een plaatshouderpagina, niet de illustratie (het is opgeslagen zonder PDF-compatibiliteit): sla het opnieuw op met PDF-compatibiliteit aan, of als PDF of SVG +@msg the file was read up to an error ({why}): what follows it was left out het bestand is gelezen tot aan een fout ({why}): wat erop volgt is weggelaten +@msg the file's preview header points outside the file de voorbeeldheader van het bestand wijst buiten het bestand +@msg the image's pixels don't match its size de pixels van de afbeelding komen niet overeen met de grootte ervan +@msg the layer has no objects to clip by de laag heeft geen objecten om mee te knippen +@msg the layer or group is locked de laag of groep is vergrendeld +@msg the guide is on a hidden or locked layer de hulplijn staat op een verborgen of vergrendelde laag +@msg the target layer is locked or hidden de doellaag is vergrendeld of verborgen +@msg the layer to flatten into is hidden de laag om in af te vlakken is verborgen +@msg the object already has an opacity mask het object heeft al een dekkingmasker +@msg the object has no geometry het object heeft geen geometrie +@msg the object has no opacity mask het object heeft geen dekkingmasker +@msg the object has no stroke het object heeft geen lijn +@msg the object is beyond the plane's horizon het object ligt voorbij de horizon van het vlak +@msg the object isn't in perspective het object is niet in perspectief +@msg the object isn't on a perspective plane het object ligt niet op een perspectiefvlak +@msg the object would cross the horizon of the perspective plane het object zou de horizon van het perspectiefvlak kruisen +@msg the objects would leave the grid de objecten zouden het raster verlaten +@msg the package came back damaged het pakket kwam beschadigd terug +@msg the pointer is beyond the plane's horizon de aanwijzer ligt voorbij de horizon van het vlak +@msg the preview image is empty de voorbeeldafbeelding is leeg +@msg the print job has no data de afdruktaak heeft geen gegevens +@msg the scale is not a usable number de schaal is geen bruikbaar getal +@msg the selected stroke's profile is already listed as {_1} het profiel van de geselecteerde lijn staat al vermeld als {_1} +@msg the selection has no URL (or they differ) de selectie heeft geen URL (of ze verschillen) +@msg the selection isn't in perspective de selectie is niet in perspectief +@msg the stroke has no weight de lijn heeft geen dikte +@msg the thumbnail image is malformed de miniatuurafbeelding is onjuist gevormd +@msg the top object must be a path, compound path or text object to use as a clipping mask het bovenste object moet een pad, samengesteld pad of tekstobject zijn om als knipmasker te gebruiken +@msg the {tool} tool has no options het gereedschap {tool} heeft geen opties +@msg there are no objects to release er zijn geen objecten om los te maken +@msg there are no printer settings to open here er zijn hier geen printerinstellingen om te openen +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it dit PostScript-bestand kan niet worden gelezen ({why}): sla het op als PDF of SVG in de app die het heeft gemaakt +@msg this file's PostScript couldn't be read ({why}): {what} de PostScript van dit bestand kon niet worden gelezen ({why}): {what} +@msg this format holds one artboard: export several as PDF or with document.exportForScreens deze indeling bevat één tekengebied: exporteer er meerdere als PDF of met document.exportForScreens +@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that dit is een DWG-tekening, die niet gelezen kan worden: sla hem in de CAD-app op als ASCII DXF en open die +@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG dit is een PostScript-bestand (een .ai in een oudere indeling of zonder PDF-compatibiliteit, of een EPS-bestand), dat nog niet geopend kan worden: sla het op als PDF, PDF-compatibel .ai of SVG +@msg this is not a PostScript file dit is geen PostScript-bestand +@msg tool shortcuts must be a single key (optionally with Shift) gereedschapsneltoetsen moeten uit één toets bestaan (eventueel met Shift) +@msg truncated afgekapt +@msg unknown command `{0}` onbekende opdracht `{0}` +@msg unknown effect `{id}` onbekend effect `{id}` +@msg unknown format onbekende indeling +@msg unknown format `{f}` (Save writes {_1}) onbekende indeling `{f}` (Opslaan schrijft {_1}) +@msg unknown freeform mode `{m}` (points, lines) onbekende vrijvormmodus `{m}` (punten, lijnen) +@msg unknown gradient kind `{k}` (linear, radial, freeform) onbekend verlooptype `{k}` (lineair, radiaal, vrijvorm) +@msg unknown highlight `{h}` onbekende markering `{h}` +@msg unknown option `{path}.{k}` onbekende optie `{path}.{k}` +@msg unknown overprints `{o}` (preserve, simulate or discard) onbekende overdruk `{o}` (behouden, simuleren of verwerpen) +@msg unknown panel `{raw}` onbekend paneel `{raw}` +@msg unknown preference `{key}` onbekende voorkeur `{key}` +@msg unknown preset (one of {_1}) onbekende voorinstelling (één van {_1}) +@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) onbekende voorinstelling `{name}` (hoog, middel, laag of een opgeslagen voorinstelling: zie flattener.presets.list) +@msg unknown preset `{name}` (see flattener.presets.list) onbekende voorinstelling `{name}` (zie flattener.presets.list) +@msg unknown preset `{name}` (see webExport.presets.list) onbekende voorinstelling `{name}` (zie webExport.presets.list) +@msg unknown side `{other}` (left|right|top|bottom) onbekende zijde `{other}` (links|rechts|boven|onder) +@msg unknown tool `{tool}` onbekend gereedschap `{tool}` +@msg unknown tool `{t}` onbekend gereedschap `{t}` +@msg unknown tool or command `{key}` onbekend gereedschap of opdracht `{key}` +@msg unknown workspace (one of {_1}) onbekende werkruimte (één van {_1}) +@msg unsupported: {0} niet ondersteund: {0} +@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files bestanden van versie {0} kunnen niet worden gecomprimeerd: alleen versie {COMPRESSED_SINCE} en later openen gecomprimeerde bestanden +@msg width and height are at least 1 pixel breedte en hoogte zijn minstens 1 pixel +@msg workspace name can't be empty de naam van de werkruimte mag niet leeg zijn +@msg {C}: no effect at index {from} {C}: geen effect bij index {from} +@msg {_1} failed: {e} {_1} mislukt: {e} +@msg {_1} files can be opened but not written (see document.formats) {_1}-bestanden kunnen worden geopend maar niet worden geschreven (zie document.formats) +@msg {_1} font families available {_1} lettertypefamilies beschikbaar +@msg {_1} is a live effect (Effect › Plug-ins) {_1} is een live-effect (Effect › Plug-ins) +@msg {_1} is already used by {_2} {_1} wordt al gebruikt door {_2} +@msg {_1} linked file(s) can't be read here: their previews show {_1} gekoppelde bestand(en) kunnen hier niet worden gelezen: hun voorbeelden worden weergegeven +@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} gekoppelde afbeelding(en) zijn gewijzigd op de schijf: Koppelingen bijwerken toont de nieuwe versies +@msg {_1}: not saved, so not closed {_1}: niet opgeslagen, dus niet gesloten +@msg {bpp}-bit bitmaps can't be read {bpp}-bits bitmaps kunnen niet worden gelezen +@msg {command} didn't create an object {command} heeft geen object gemaakt +@msg {label} produced an empty result {label} leverde een leeg resultaat op +@msg {label}: lost track of a shape's style {label}: de stijl van een vorm is kwijtgeraakt +@msg {label}: select paths {label}: selecteer paden +@msg {label}: select paths, compound paths, groups or text {label}: selecteer paden, samengestelde paden, groepen of tekst +@msg {label}… {label}… +@msg {name}: CRC mismatch {name}: CRC komt niet overeen +@msg {name}: no local header {name}: geen lokale header +@msg {name}: no such recovery entry {name}: herstelitem bestaat niet +@msg {path} can't be found: relink it first {path} kan niet worden gevonden: koppel het eerst opnieuw +@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: hier is geen bestandssysteem (geef dataBase64 door om te openen; laat het pad weg om dataBase64 te krijgen) +@msg {path}: not a file {path}: geen bestand +@msg {path}: {e} {path}: {e} +@msg {status} ({e}) {status} ({e}) +@msg Couldn't place {name}: {e} Kon {name} niet plaatsen: {e} +@msg Couldn't paste from the clipboard: {e} Kon niet van het klembord plakken: {e} +@msg Saved {path} with {_1} note(s): {first} {path} opgeslagen met {_1} opmerking(en): {first} +@msg Exported {what} with {_1} note(s): {first} {what} geëxporteerd met {_1} opmerking(en): {first} +@msg Exported {what} {what} geëxporteerd +@msg Saved {n} files beside {path} {n} bestanden opgeslagen naast {path} +@msg Saving Bezig met opslaan +@msg Saving {_1} Bezig met opslaan van {_1} +@msg Exporting {_1} Bezig met exporteren van {_1} +@msg Saving recovery data for {_1} Bezig met opslaan van herstelgegevens voor {_1} +@msg Discarded {_1} recovered document(s) {_1} hersteld(e) document(en) verworpen +@msg Restored {_1} recovered document(s): save them to keep them {_1} hersteld(e) document(en) teruggezet: sla ze op om ze te behouden +@msg Embedded {n} image(s); {missing} missing: relink them first {n} afbeelding(en) ingesloten; {missing} ontbreken: koppel ze eerst opnieuw +@msg Embedded {n} image(s) {n} afbeelding(en) ingesloten +@msg Relinked {_1} image(s) {_1} afbeelding(en) opnieuw gekoppeld +@msg Relinked {n} image(s) {n} afbeelding(en) opnieuw gekoppeld +@msg Relinked {n} image(s); not in that folder: {_1} {n} afbeelding(en) opnieuw gekoppeld; niet in die map: {_1} +@msg Updated {_1} image(s) {_1} afbeelding(en) bijgewerkt +@msg Unembedded to {_1} Extern gemaakt: {_1} +@msg Reset {_1} {_1} opnieuw ingesteld +@msg Sent to {_1} Verzonden naar {_1} +@msg {_1} files {_1}-bestanden +@msg The graphics were lost and have been restarted De grafische weergave is verloren gegaan en opnieuw gestart +@msg The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary} De grafische processor die eerst werd geprobeerd, kon het venster niet weergeven, dus VectorCraft is opnieuw gestart met {summary} +@msg Loading VectorCraft… VectorCraft laden… +@msg VectorCraft failed to start: {e} VectorCraft kon niet starten: {e} +@msg A browser with WebGPU or WebGL2 is required. Er is een browser met WebGPU of WebGL2 vereist. +@msg The graphics were lost and couldn't be restarted. De grafische weergave is verloren gegaan en kon niet opnieuw worden gestart. +@msg Your unsaved changes are kept in this browser: reload the page to get them back. Je niet-opgeslagen wijzigingen worden in deze browser bewaard: laad de pagina opnieuw om ze terug te krijgen. +@msg Reload the page to go on (changes not saved yet may be lost). Laad de pagina opnieuw om door te gaan (nog niet opgeslagen wijzigingen kunnen verloren gaan). +@msg tool (a tool id) is required tool (een gereedschaps-id) is vereist +@msg {tool} isn't in a toolbar group of several tools {tool} zit niet in een werkbalkgroep met meerdere gereedschappen +@msg onto must name a panel floating in another group onto moet een zwevend paneel in een andere groep benoemen +@msg below and above must name a panel floating in another group below en above moeten een zwevend paneel in een andere groep benoemen +@msg give only one of onto, below and above geef slechts één van onto, below en above +@msg column must be true or a number from 1 column moet true zijn of een getal vanaf 1 +@msg x and y must both be numbers x en y moeten allebei getallen zijn +@msg "{0}" is not a colour: write it as #rrggbb "{0}" is geen kleur: schrijf het als #rrggbb +@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead dit is geen logo met effen kleuren ({_1}% van de effen vlakken komt overeen met enkele effen kleuren), dus de modus Effen logo zou de verlopen in vlakken veranderen: gebruik in plaats daarvan de modus Kleur +@msg `{what}` closes nothing `{what}` sluit niets +@msg `{what}` closes something it didn't open `{what}` sluit iets dat het niet heeft geopend +@msg a path has too many points een pad heeft te veel punten +@msg it ends inside a layer het eindigt binnen een laag +@msg it has art outside its layers het heeft illustratie buiten zijn lagen +@msg it has more than {MAX_NODES} objects het heeft meer dan {MAX_NODES} objecten +@msg it has no layers het heeft geen lagen +@msg it has too many values in a row het heeft te veel waarden achter elkaar +@msg it has {_1}, which VectorCraft doesn't read from it yet het bevat {_1}, wat VectorCraft er nog niet uit leest +@msg its objects are nested too deeply de objecten ervan zijn te diep genest +@msg its section ending with `%{end}` doesn't end zijn sectie die op `%{end}` eindigt, eindigt niet +@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objecten met meerdere vullingen of lijnen, effecten of penselen zijn als hun getekende uiterlijk binnengekomen ({_1}): hun vormgeving is nog niet bewerkbaar +@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it verloop „{name}” is niet gedefinieerd in het bestand: er is een effen kleur voor gebruikt +@msg an image VectorCraft can't read was left out een afbeelding die VectorCraft niet kan lezen, is weggelaten +@msg an image whose data is cut short was left out een afbeelding waarvan de gegevens zijn afgekapt, is weggelaten +@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel gekoppelde afbeeldingen tonen het in het bestand opgeslagen voorbeeld met lage resolutie totdat hun bestanden zijn gevonden: koppel ze opnieuw in het paneel Koppelingen +@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise vlaktekst in kaders die te kort zijn voor één regel van het lettertype waarin de tekst wordt weergegeven, heeft hogere kaders gekregen ({_1}): anders zou de tekst niet zichtbaar zijn +@msg its editing data doesn't say where its art is zijn bewerkingsgegevens zeggen niet waar de illustratie zich bevindt +@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) dit bestand is opgeslagen zonder PDF-compatibiliteit en de bewerkingsgegevens kunnen niet worden gelezen ({why}) +@msg No fonts are missing in this document Er ontbreken geen lettertypen in dit document +@msg VectorCraft doesn't search other apps' or the system's folders: {path} VectorCraft doorzoekt geen mappen van andere apps of van het systeem: {path} +@msg VectorCraft doesn't search the Shared or Public folder in Users: {path} VectorCraft doorzoekt de map Gedeeld of Openbaar onder Gebruikers niet: {path} +@msg {path} is not a folder {path} is geen map +@msg the search couldn't start: try again in a moment de zoekopdracht kon niet starten: probeer het over een ogenblik opnieuw +@msg searching a folder isn't available on the web een map doorzoeken is niet beschikbaar op het web +@msg every search thread stopped on an internal error (please report this bug) elke zoekthread is gestopt door een interne fout (meld deze bug) +@msg VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it de map Lettertypen van VectorCraft is hier niet ingesteld: de desktopapp en vectorcraft-cli stellen die in +@msg choose the font files to add kies de lettertypebestanden die je wilt toevoegen +@msg {n} font file(s) are now in {folder} er staan nu {n} lettertypebestand(en) in {folder} +@msg {n} font file(s) are now in {folder}; {failed} couldn't be added: {reason} er staan nu {n} lettertypebestand(en) in {folder}; {failed} konden niet worden toegevoegd: {reason} +@msg {n} font(s) aren't available here: their text shows in another font {n} lettertype(n) hier niet beschikbaar: de tekst verschijnt in een ander lettertype +@msg not a file geen bestand +@msg not a font file geen lettertypebestand +@msg that document is no longer open dat document is niet langer geopend +@msg larger than 256 MB groter dan 256 MB +@msg the files come to more than 1 GB: add fewer at a time de bestanden lopen op tot meer dan 1 GB: voeg er minder tegelijk toe +@msg every numbered name for it is taken in the Fonts folder elke genummerde naam ervoor is al vergeven in de map Lettertypen +@msg every numbered name for {name} is taken in the document's folder elke genummerde naam voor {name} is al vergeven in de map van het document +@msg No linked files to look for Geen gekoppelde bestanden om naar te zoeken +@msg Relinked {n} image(s); {name} couldn't be relinked: {reason} {n} afbeelding(en) opnieuw gekoppeld; {name} kon niet opnieuw worden gekoppeld: {reason} +@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` is de stapverwijzing {r}, die alleen `run` en `command_batch` oplossen +@msg `ids` must be an array of object ids, not {v} `ids` moet een array van object-id's zijn, niet {v} +@msg `corners` must be an array of anchor indices, not {v} `corners` moet een array van ankerindices zijn, niet {v} +@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: het resultaat van stap {i} heeft geen dergelijke waarde (schrijf $$ voor een letterlijke $) +@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: er is geen stap {i} vóór deze stap (stappen tellen vanaf 0; schrijf $$ voor een letterlijke $) +@msg object ids are non-negative integers, not {v} object-id's zijn niet-negatieve gehele getallen, niet {v} +@msg automation filesystem access is not granted: read authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de leesbevoegdheid ontbreekt: {path} +@msg automation filesystem access is not granted: write authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de schrijfbevoegdheid ontbreekt: {path} +@msg automation path rejected: outside the read root {root}: {path} automatiseringspad afgewezen: buiten de leesroot {root}: {path} +@msg automation path rejected: outside the write root {root}: {path} automatiseringspad afgewezen: buiten de schrijfroot {root}: {path} +@msg automation path rejected: path is empty automatiseringspad afgewezen: het pad is leeg +@msg automation path rejected: a link that leads nowhere: {path} automatiseringspad afgewezen: een koppeling die nergens heen leidt: {path} +@msg automation path rejected: cannot resolve {path}: {e} automatiseringspad afgewezen: kan {path} niet oplossen: {e} +@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} automatiseringspad afgewezen: bovenliggende map doorlopen onder een map die niet bestaat: {path} +@msg automation path rejected: reserved device names are not allowed: {path} automatiseringspad afgewezen: gereserveerde apparaatnamen zijn niet toegestaan: {path} +@msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode Multikanaal-PSD-bestanden kunnen niet worden gelezen: sla het bestand op in de modus RGB, CMYK of Grijswaarden +@msg not a PSD file geen PSD-bestand +@msg the PSD file has an unknown colour mode ({m}) het PSD-bestand heeft een onbekende kleurmodus ({m}) +@msg the PSD file's image data uses an unknown compression ({m}) de afbeeldingsgegevens van het PSD-bestand gebruiken een onbekende compressie ({m}) +@msg the PSD image is too large to read ({_1} × {_2} pixels) de PSD-afbeelding is te groot om te lezen ({_1} × {_2} pixels) +@msg zipped 32-bit PSD image data with prediction can't be read gezipte 32-bits PSD-afbeeldingsgegevens met voorspelling kunnen niet worden gelezen +@msg {depth}-bit PSD files in this colour mode can't be read {depth}-bits PSD-bestanden in deze kleurmodus kunnen niet worden gelezen +@msg the PSD file is cut short het PSD-bestand is afgekapt +@msg the PSD file's header is damaged de header van het PSD-bestand is beschadigd +@msg the PSD file's image data can't be read: {e} de afbeeldingsgegevens van het PSD-bestand kunnen niet worden gelezen: {e} +@msg Imported library {name} Bibliotheek {name} geïmporteerd +@msg Invalid panel layout; restored the default dock Ongeldige paneelindeling; het standaarddok is hersteld +@msg action is required actie is vereist +@msg anchor must name a registered panel anchor moet een geregistreerd paneel benoemen +@msg before must name a panel before moet een paneel benoemen +@msg docking action contains an unknown panel or an excessive split path dokactie bevat een onbekend paneel of een overmatig splitspad +@msg group must be true or false group moet true of false zijn +@msg move requires a destination panel move vereist een doelpaneel +@msg move requires an existing destination panel move vereist een bestaand doelpaneel +@msg onto must name a floating panel onto moet een zwevend paneel benoemen +@msg panel group is empty paneelgroep is leeg +@msg panel must name a registered panel panel moet een geregistreerd paneel benoemen +@msg saved panel layout is invalid; reset the workspace first opgeslagen paneelindeling is ongeldig; stel eerst de werkruimte opnieuw in +@msg zone must be a string zone moet een string zijn +@msg zone must be tab, center, left, right, top or bottom zone moet tab, center, left, right, top of bottom zijn +@msg {key} must be a finite coordinate {key} moet een eindige coördinaat zijn +@msg x and y must be provided together x en y moeten samen worden opgegeven +@msg invalid restored panel group ongeldige herstelde paneelgroep +@msg invalid native panel set ongeldige systeemeigen paneelset +@msg missing panel layout ontbrekende paneelindeling +@msg unsupported native docking operation niet-ondersteunde systeemeigen dokbewerking +@msg this kind of brush has no options dialog yet dit soort penseel heeft nog geen dialoogvenster met opties + Ruler Origin Lineaaloorsprong + Reset Ruler Origin Lineaaloorsprong herstellen + Column Width: Kolombreedte: + Import Data… Gegevens importeren… + Number of Decimals: Aantal decimalen: + Switch x/y x/y omwisselen + Transpose Transponeren + Phone 393×852 Telefoon 393×852 + Phone 430×932 Telefoon 430×932 + Phone 360×800 Telefoon 360×800 + Phone 412×915 Telefoon 412×915 + Phone 375×667 Telefoon 375×667 + Tablet 768×1024 Tablet 768×1024 + Tablet 820×1180 Tablet 820×1180 + Tablet 834×1194 Tablet 834×1194 + Tablet 1024×1366 Tablet 1024×1366 + Tablet 800×1280 Tablet 800×1280 + Watch 184×224 Horloge 184×224 + Watch 198×242 Horloge 198×242 + Web 1600×900 Web 1600×900 + Web 1440×900 Web 1440×900 + Web 1366×768 Web 1366×768 + Web 1280×800 Web 1280×800 + Web 1024×768 Web 1024×768 + Legal Legal + Tabloid Tabloid + A3 A3 + A4 A4 + A5 A5 + B4 B4 + B5 B5 + HDTV 1080p HDTV 1080p + HDTV 720p HDTV 720p + 4K UHD 4K UHD + 8K UHD 8K UHD + 2K Cinema 2K Cinema + 4K Cinema 4K Cinema + Greeting Card Wenskaart + Square 8 × 8 in Vierkant 8 × 8 in + Poster 18 × 24 in Poster 18 × 24 in + Poster 24 × 36 in Poster 24 × 36 in + Logo 1000×1000 Logo 1000×1000 + Business Card 3.5 × 2 in Visitekaartje 3.5 × 2 in + Business Card 85 × 55 mm Visitekaartje 85 × 55 mm + Letterhead Letter Briefhoofd Letter + Letterhead A4 Briefhoofd A4 + Envelope DL Envelop DL + Envelope #10 Envelop #10 + Social Portrait Post 1080×1350 Sociale staande post 1080×1350 + Social Landscape Post 1200×630 Sociale liggende post 1200×630 + Social Story 1080×1920 Sociale story 1080×1920 + Social Tall Post 1000×1500 Sociale lange post 1000×1500 + Profile Cover 1500×500 Profielomslag 1500×500 + Page Cover 1640×624 Paginaomslag 1640×624 + Video Banner 2560×1440 Videobanner 2560×1440 + Video Thumbnail 1280×720 Videominiatuur 1280×720 + Keyboard Shortcuts Sneltoetsen + Set: Set: + Reset to Defaults Standaardwaarden herstellen + Tool Gereedschap + Command Opdracht + Shortcut Sneltoets + Menu Commands Menuopdrachten + Press keys… Druk op toetsen… + Use Default Standaard gebruiken + Go to Conflict Naar conflict gaan + Remove the shortcut Sneltoets verwijderen + Click, then press the new shortcut (Esc cancels) Klik en druk vervolgens de nieuwe sneltoets in (Esc annuleert) + All shortcuts reset to defaults. Alle sneltoetsen zijn teruggezet op de standaardwaarden. + Imported. Press OK to keep the imported set. Geïmporteerd. Druk op OK om de geïmporteerde set te behouden. + ⚠ {chord} was already used by {names} — it has been removed there. Waarschuwing: {chord} werd al gebruikt door {names} — hij is daar verwijderd. + VectorCraft Defaults VectorCraft-standaard +@plural {n} shortcut is assigned more than once|{n} shortcuts are assigned more than once {n} sneltoets is meer dan één keer toegewezen|{n} sneltoetsen zijn meer dan één keer toegewezen + Simulate prints overprints as Overprint Preview shows them; separations always keep them. A preset flattens transparency before printing (PostScript files always flatten it). Simuleren drukt overdrukken af zoals het Overdrukvoorbeeld ze toont; scheidingen behouden ze altijd. Een voorinstelling vlakt transparantie af vóór het afdrukken (PostScript-bestanden vlakken die altijd af). + Preserve Behouden + Simulate Simuleren + Portrait (flipped) Staand (gespiegeld) + Landscape (flipped) Liggend (gespiegeld) + Visible & Printable Layers Zichtbare & afdrukbare lagen + Visible Layers Zichtbare lagen + All Layers Alle lagen + Do Not Scale Niet schalen + Fit to Page Aanpassen aan de pagina + Custom Scale Aangepaste schaal + Tile Imageable Areas Afdrukbare gebieden tegelen + Composite Composiet + Separations Scheidingen + Up (right reading) Boven (correct leesbaar) + Down (right reading) Onder (correct leesbaar) + Positive Positief + Negative Negatief + Subset Subset + Complete Compleet + [Custom] [Aangepast] + [Default] [Standaard] + No downsampling Geen downsampling + Subsample Subsampling + Bicubic Bicubisch + Minimum Minimum + Run Length Run Length + Roman Roman + No conversion Geen conversie + Convert to destination Converteren naar bestemming + Convert to destination (preserve numbers) Converteren naar bestemming (getallen behouden) + Don't include profiles Geen profielen opnemen + Include all profiles Alle profielen opnemen + Include destination profile Bestemmingsprofiel opnemen + Include tagged source profiles Gelabelde bronprofielen opnemen + Low resolution (150 ppi) Lage resolutie (150 ppi) + High resolution Hoge resolutie + Inserting, deleting and rotating pages Pagina's invoegen, verwijderen en roteren + Filling in form fields and signing Formuliervelden invullen en ondertekenen + Commenting, filling in form fields and signing Commentaar toevoegen, formuliervelden invullen en ondertekenen + Any except extracting pages Alles behalve pagina's extraheren + Bounding Box Begrenzingskader + Mobile Mobiel + Film & Video Film & video + Art & Illustration Kunst & illustratie + Branding Branding + Social Social + Triangle Driehoek + Triangle (open) Driehoek (open) + Circle (open) Cirkel (open) + Square (open) Vierkant (open) + Bar Balk + Arrow Pijl + Arrow (open) Pijl (open) + Barbed Weerhaak + Half Arrow (left) Halve pijl (links) + Half Arrow (right) Halve pijl (rechts) + Concave Concaaf + Double Bar Dubbele balk + Feather Veer + Dot on Bar Punt op balk + Chevron Chevron + Double Arrow Dubbele pijl + Cross Kruis + Plus Plus + Hexagon Zeshoek + Hexagon (open) Zeshoek (open) + Tag Label + Tag (open) Label (open) + Half Circle Halve cirkel + Drop Druppel + Slash Schuine streep + Double Slash Dubbele schuine streep + Diamond (open) Ruit (open) + Reverse Triangle Omgekeerde driehoek + Swallowtail Zwaluwstaart + Bracket Haak + Fork Vork + Leaf Blad + Kite Vlieger + Triangle to Bar Driehoek naar balk + Oval Ovaal + Complementary Complementair + Complementary 2 Complementair 2 + Split Complementary Gesplitst complementair + Left Complement Links complement + Right Complement Rechts complement + Analogous Analoog + Analogous 2 Analoog 2 + Monochromatic Monochroom + Shades Schakeringen + Triad Triade + Triad 2 Triade 2 + Triad 3 Triade 3 + Tetrad Tetrade + Tetrad 2 Tetrade 2 + Tetrad 3 Tetrade 3 + Compound Samengesteld + Compound 2 Samengesteld 2 + High Contrast Hoog contrast + High Contrast 2 Hoog contrast 2 + High Contrast 3 Hoog contrast 3 + Pentagram Pentagram + Cool Koel + Warm Warm + Muted Gedempt + Vivid Levendig + Spectrum Spectrum + Perceptual Perceptueel + Relative Colorimetric Relatief colorimetrisch + Absolute Colorimetric Absoluut colorimetrisch + Exact Exact + Preserve Tints Tinten behouden + Scale Tints Tinten schalen + Linear Lineair + Freeform Vrijvorm + 8 Colors 8 kleuren + 16 Colors 16 kleuren + 256 Colors 256 kleuren + True Colors Truecolor + Preserve Appearance Vormgeving behouden + Maximize Editability Bewerkbaarheid maximaliseren + Clip Group Knipgroep + Guide Hulplijn + Symbol Symbool + Envelope Omvorming + Mesh Net + Placed Document Geplaatst document + Radial Repeat Radiale herhaling + Grid Repeat Rasterherhaling + Mirror Repeat Spiegelherhaling + Auto Size Automatisch formaat + Shrink Text to Fit Tekst verkleinen om te passen + Ascent Stijghoogte + Cap Height Kaphoogte + x Height x-hoogte + Justify Uitvullen + First Baseline Eerste basislijn + Vertical Align Verticaal uitlijnen + Save Selection Selectie opslaan + No Legend Geen legenda + Standard Legend Standaardlegenda + Legends in Wedges Legenda's in segmenten + Ratio Verhouding + Even Gelijkmatig + Stacked Gestapeld + Twirl Draaien + Pucker Krimpen + Bloat Opblazen + Scallop Geschulpt + Wrinkle Rimpelen + Unknown Onbekend + Copyrighted Auteursrechtelijk beschermd + Public Domain Publiek domein + None (Color Preview) Geen (kleurvoorbeeld) + Rasterized Complex Regions Gerasterde complexe gebieden + Transparent Objects Transparante objecten + All Affected Objects Alle beïnvloede objecten + Expanded Patterns Uitgebreide patronen + Outlined Strokes Omgezette lijnen + Outlined Text Omgezette tekst + All Rasterized Regions Alle gerasterde gebieden + High Resolution Hoge resolutie + [High Resolution] [Hoge resolutie] + Medium Resolution Middelmatige resolutie + [Medium Resolution] [Middelmatige resolutie] + Low Resolution Lage resolutie + [Low Resolution] [Lage resolutie] + Copyright and Contact Info Auteursrecht en contactinformatie + All Slices Alle slices + Selected Slices Geselecteerde slices + No Slices Geen slices + Images Only Alleen afbeeldingen + HTML and Images HTML en afbeeldingen + Selective Selectief + Adaptive Adaptief + Black & White Zwart-wit + No Dither Geen dithering + Diffusion Diffusie + Unsorted Ongesorteerd + Sort by Hue Sorteren op tint + Sort by Luminance Sorteren op helderheid + Sort by Popularity Sorteren op populariteit + Baseline (Standard) Basislijn (standaard) + Baseline Optimized Basislijn geoptimaliseerd + Art Optimized Geoptimaliseerd voor illustraties + Type Optimized Geoptimaliseerd voor tekst + Language Level 2 Taalniveau 2 + Language Level 3 Taalniveau 3 + TIFF (Black & White) TIFF (zwart-wit) + TIFF (8-bit Color) TIFF (8-bits kleur) + 1 bit 1 bit + 4 bit 4 bit + 8 bit 8 bit + 16 bit 16 bit + 24 bit 24 bit + 32 bit 32 bit + 16 bits/pixel 16 bits/pixel + 24 bits/pixel 24 bits/pixel + 32 bits/pixel 32 bits/pixel + DWG (use DXF) DWG (gebruik DXF) + Artistic Artistiek + Film Grain… Filmkorrel… + Highlight Area Hooglichtgebied +file-filter All readable files Alle leesbare bestanden +file-filter All placeable files Alle plaatsbare bestanden +file-filter Swatch libraries Stalenbibliotheken +file-filter Flattener presets Afvlakvoorinstellingen +file-filter PDF presets PDF-voorinstellingen +file-filter Print presets Afdrukvoorinstellingen +file-filter SVG Compressed Gecomprimeerde SVG +file-filter PDF-compatible .ai PDF-compatibel .ai +file-filter VectorCraft Template VectorCraft-sjabloon +file-filter Templates Sjablonen +diagnostics render render +diagnostics ui ui + Preset {number} Voorinstelling {number} + PRs PR's + @{login}: {prs}, {commits}, +{added} / −{deleted} lines (Δ {delta}), +{binary_added} / −{binary_deleted} binary assets, {first} – {last} @{login}: {prs}, {commits}, +{added} / −{deleted} regels (Δ {delta}), +{binary_added} / −{binary_deleted} binaire assets, {first} – {last} +@plural {n} PR|{n} PRs {n} PR|{n} PR's +@plural {n} commit|{n} commits {n} commit|{n} commits +gradient-kind Radial Radiaal +mark-style Japanese Japans +@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) het gecomprimeerde bestand pakt uit tot meer dan VectorCraft leest ({_1} MB document, {_2} MB per afbeelding) +@msg the document is too large to save compressed ({0}): save it with Use Compression off het document is te groot om gecomprimeerd op te slaan ({0}): sla het op met Compressie gebruiken uit +@msg the file stopped being read (please report this bug) het lezen van het bestand is gestopt (meld deze bug) + Concentric Dividers: Concentrische verdelers: + Radial Dividers: Radiale verdelers: + Skew: Scheefheid: + Inside Binnen + Outside Buiten + Use Outside Rectangle As Frame Buitenste rechthoek als kader gebruiken + Create Compound Path From Ellipses Samengesteld pad van ellipsen maken + Fill Grid Raster vullen + Select two or more objects, or a group Selecteer twee of meer objecten, of een groep + Back Achterkant + Both sides Beide zijden + Classic Klassiek + Cove Cove + Depth Diepte + Front Voorkant + Isometric Bottom Isometrisch onder + Isometric Left Isometrisch links + Isometric Right Isometrisch rechts + Isometric Top Isometrisch boven + Off-Axis Back Niet-axiaal achter + Off-Axis Bottom Niet-axiaal onder + Off-Axis Front Niet-axiaal voor + Off-Axis Left Niet-axiaal links + Off-Axis Right Niet-axiaal rechts + Off-Axis Top Niet-axiaal boven + Ogee Ogee + Reset all 3D settings to their defaults. Zet alle 3D-instellingen terug op de standaardwaarden. + Step Stap + Taper Taps + Twist Torsie + Volume Volume +3d Cap Dop +3d Solid Massief +3d Hollow Hol +3d Bevel Afschuining +3d Rounded Afgerond +3d Space Ruimte + Bevel Shape Afschuiningsvorm + Bevel Inside Afschuining aan de binnenzijde + Bevel both sides Afschuining aan beide zijden + Presets Voorinstellingen +@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Een object kan maar één 3D-effect hebben. Pas de opties van het bestaande effect aan of verwijder het eerst. +@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} vereist momenteel een pad of samengesteld pad. Zet tekst, afbeeldingen en groepen eerst om naar paden. +@msg {label} requires a shape with at least two anchors {label} vereist een vorm met minstens twee ankerpunten +@msg The shape is too complex for a 3D effect De vorm is te complex voor een 3D-effect +@msg The 3D surface is too complex; simplify the shape Het 3D-oppervlak is te complex; vereenvoudig de vorm + Transform Objects Objecten transformeren +@msg unknown parameter `{key}`; did you mean `{near}`? onbekende parameter `{key}`; bedoelde je `{near}`? +@msg unknown parameter `{key}`; the command takes no parameters onbekende parameter `{key}`; de opdracht accepteert geen parameters +@msg unknown parameter `{key}`; the command takes: {keys} onbekende parameter `{key}`; de opdracht accepteert: {keys} + Load EPS into Clipboard EPS in het klembord laden +@msg an image of {w} × {h} pixels is too large to draw een afbeelding van {w} × {h} pixels is te groot om te tekenen + Fill and Stroke Controls Besturingselementen voor vulling en lijn + Coloring Controls Besturingselementen voor kleuren + Draw Mode Controls Besturingselementen voor tekenmodus + Screen Mode Controls Besturingselementen voor schermmodus + Drawer menu Lademenu + Toolbar: Show Controls Werkbalk: besturingselementen tonen +@msg control must be fillStroke, coloring, drawMode or screenMode control moet fillStroke, coloring, drawMode of screenMode zijn +@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode onbekend besturingselement `{name}`: fillStroke, coloring, drawMode of screenMode +@msg give a fraction `t` from 0 to 1 geef een fractie `t` van 0 tot 1 +@msg give the path `id` geef de `id` van het pad +@msg invalid stroke widths ongeldige lijndiktes +@msg object has no weighted stroke object heeft geen lijn met een breedteprofiel + Single Corner Radius Eén hoekradius + Top Left: Linksboven: + Top Right: Rechtsboven: + Bottom Left: Linksonder: + Bottom Right: Rechtsonder: + Softness Zachtheid + Ambient Light Omgevingslicht + Light behind the object Licht achter het object + 2up 2up + 4up 4up + A6 A6 + Convert to Global Converteren naar globaal + Grey Grijs + Text Displayed in Cell Tekst weergegeven in cel + Working RGB Werkend RGB + Art and Bright Colors Illustraties en heldere kleuren + Dot Gain 15% Puntvergroting 15% + Expand 1 Selected 1 geselecteerd object uitbreiden + Expand Object Object uitbreiden + Gray Gamma 2.2 Grijs gamma 2.2 + Phone 375×812 Telefoon 375×812 + Photo Low Fidelity Foto lage getrouwheid + Print & Proof Afdruk & proef + Printing & Proofing Afdrukken & proefdrukken + Restricted Colors Beperkte kleuren + Simplified Chinese Vereenvoudigd Chinees + Tablet 1024×768 Tablet 1024×768 + Video 1920×1080 Video 1920×1080 + Video 3840×2160 Video 3840×2160 + Web 1280×720 Web 1280×720 +@msg give corners of the path: anchors between two straight sides geef de hoeken van het pad: ankers tussen twee rechte zijden + [1P-High View] [1P-hoog aanzicht] + [1P-Low View] [1P-laag aanzicht] + [1P-Normal View] [1P-normaal aanzicht] + [2P-High View] [2P-hoog aanzicht] + [2P-Low View] [2P-laag aanzicht] + [2P-Normal View] [2P-normaal aanzicht] + [3P-Low View] [3P-laag aanzicht] + [3P-Normal View] [3P-normaal aanzicht] + Automation Automatisering + Essentials Essentials + Essentials Classic Essentials Classic + Layout Layout + OpenType OpenType + Painting Schilderen + Printing and Proofing Afdrukken en proefdrukken + Tracing Tracering + Typography Typografie + VectorCraft VectorCraft + Web Web + Decrease Font Size by Five Steps Tekengrootte met vijf stappen verkleinen + Increase Font Size by Five Steps Tekengrootte met vijf stappen vergroten + Intertwine Verstrengelen + Arrange Intertwine Area Verstrengelingsgebied schikken + Rotate View to Selection Weergave naar selectie draaien +@msg select an Intertwine group selecteer een verstrengelingsgroep +@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS dit is een PostScript-bestand (een .ai dat in een ouder formaat of zonder PDF-compatibiliteit is opgeslagen, of een EPS-bestand), geen PDF: Bestand › Openen en Bestand › Plaatsen lezen het als EPS +@msg The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set De laatste start van VectorCraft eindigde op {name} voordat het venster verscheen, dus het is gestart op {summary}. Om die grafische processor opnieuw te proberen, wijzig Voorkeuren › Prestaties › Grafische processor of start met WGPU_BACKEND ingesteld +@msg The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set De laatste start van VectorCraft eindigde tijdens het weergeven van de grafische processors, dus het is gestart op {summary} met alleen OpenGL. Om de andere grafische backends opnieuw te proberen, wijzig Voorkeuren › Prestaties › Grafische processor of start met WGPU_BACKEND ingesteld + Loaded Texture Geladen textuur + Load Texture… Textuur laden… + Texture Image Textuurafbeelding +@msg the image can't be read as a texture de afbeelding kan niet als textuur worden gelezen + Legacy (version 7) Verouderd (versie 7) + Transparent Images: Transparante afbeeldingen: + Flatten Platmaken + Omit Weglaten + Legacy .ai Options Verouderde .ai-opties + A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Een verouderd .ai-bestand bevat één tekengebied: elk tekengebied dat je aanvinkt, wordt als afzonderlijk bestand opgeslagen. + {count} of {total} artboards {count} van {total} tekengebieden + Check an artboard to save. Vink een tekengebied aan om het op te slaan. + Hidden Objects: Verborgen objecten: + Hidden Layer Verborgen laag + Make Visible Zichtbaar maken + Text: Tekst: + Editable Bewerkbaar + PDF-compatible PDF-compatibel + Dimension Maatlijn + Drawing : real size — 1:10 labels 1 unit on the page as 10. Tekening : werkelijke grootte — 1:10 labelt 1 eenheid op de pagina als 10. + Preview (1 inch on the page) Voorbeeld (1 inch op de pagina) + Set as Default Als standaard instellen + out from a highlighted edge to place its dimension | uit een gemarkeerde rand om de bijbehorende maatlijn te plaatsen | + cancels | Double-click the tool for options annuleert | Dubbelklik op het gereedschap voor opties + Angular Hoek + Away from the Line Van de lijn af + Between Line and Object Tussen lijn en object + Drag out from a segment, or click two points, then click to place. Sleep vanuit een segment, of klik twee punten, en klik daarna om te plaatsen. + Drag out from a corner, or click two straight lines, then click to place. Sleep vanuit een hoekpunt, of klik twee rechte lijnen, en klik daarna om te plaatsen. + Drag out from a circle or arc for its radius, inwards for its diameter. Sleep vanuit een cirkel of boog voor de straal, naar binnen voor de diameter. +@msg Choose a circle or a perfect arc Kies een cirkel of een perfecte boog +@msg Choose a precision from 0 to 6 Kies een precisie van 0 tot 6 + Create Dimension Maatlijn maken + Dimension Tool Maatlijngereedschap + Dimension Tool Options Opties voor maatlijngereedschap + Display Weergave + Font Family: Lettertypefamilie: + Font Style: Letterstijl: + Precision: Precisie: + Update Dimensions Maatlijnen bijwerken +@msg Angles require two straight segments Hoeken vereisen twee rechte segmenten +@msg Choose two different points Kies twee verschillende punten +@msg Choose two non-parallel straight segments Kies twee niet-parallelle rechte segmenten +@msg Choose two nonzero angle arms Kies twee hoekbenen die niet nul zijn +@msg Dimension point is outside the canvas Maatlijnpunt ligt buiten het canvas +@msg Dimension size is outside its supported range Maatlijngrootte valt buiten het ondersteunde bereik +@msg First measured segment no longer exists Eerste gemeten segment bestaat niet meer +@msg Invalid dimension distance Ongeldige maatlijnafstand +@msg Invalid dimension font name Ongeldige lettertypenaam voor de maatlijn +@msg Invalid drawing scale Ongeldige tekeningsschaal +@msg Invalid real scale Ongeldige werkelijke schaal +@msg Measured anchor no longer exists Gemeten ankerpunt bestaat niet meer +@msg Measured segment no longer exists Gemeten segment bestaat niet meer +@msg Non-finite dimension Niet-eindige maatlijn +@msg Scale must be drawing:real, for example 1:10 Schaal moet tekening:werkelijk zijn, bijvoorbeeld 1:10 +@msg Scale ratio must be between 1:1000000 and 1000000:1 Schaalverhouding moet tussen 1:1000000 en 1000000:1 liggen +@msg Second measured segment no longer exists Tweede gemeten segment bestaat niet meer +@msg Unknown dimension mode Onbekende maatlijnmodus +@msg Unknown dimension option {k} Onbekende maatlijnoptie {k} +@msg Unknown dimension style Onbekende maatlijnstijl +@msg Unknown unit Onbekende eenheid + Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Sluit de ronde uiteinden van open profielen. Gedeeltelijke omwentelingen houden hun afgesneden zijden open. + Darkness Donkerte + Isolate Selected Object Geselecteerd object isoleren + Keep selected Geselecteerd houden + Sample Color from Screen Kleur van het scherm opnemen + The active document changed while the screen color sampler was open Het actieve document is gewijzigd terwijl de schermkleurkiezer open was + This system has no screen color sampler Dit systeem heeft geen schermkleurkiezer + Color blindness – Tritanopia-type Kleurenblindheid – type tritanopie + Panels Except Tools and Control Alle panelen behalve Gereedschap en Bediening + Reset Tracking and Kerning Spatiëring en kerning herstellen + Reset Horizontal Scale Horizontale schaal herstellen +@msg span must be "object" or "selection", got {v} span moet "object" of "selection" zijn, maar is {v} +@msg span: "selection" places a `gradient`, not a swatch span: "selection" plaatst een `gradient`, geen staal +@msg span: "selection" places a gradient: give `gradient` span: "selection" plaatst een verloop: geef `gradient` +@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" plaatst een lineair of radiaal verloop, geen vrij verloop +@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" plaatst het verloop zelf: laat `start` en `end` weg (die plaatsen het al in documentcoördinaten) + Make Image Dynamic Afbeelding dynamisch maken + Import Data Sets… Gegevenssets importeren… + Export Data Sets… Gegevenssets exporteren… + Make Graph Dynamic Grafiek dynamisch maken + Batch… Batch… + Source Folder: Bronmap: + Destination Folder: Doelmap: + Overrides Overschrijvingen + Uses every data set of the open document. Gebruikt elke gegevensset van het geopende document. +@msg Batch: {count} processed Batch: {count} verwerkt +@msg Batch: {count} processed, {failed} failed Batch: {count} verwerkt, {failed} mislukt +@msg choose a destination folder kies een doelmap +@msg choose a source folder kies een bronmap +@msg open a document with data sets open een document met gegevenssets +@msg the action has no steps de actie heeft geen stappen + Registration: Passermarkering: + By Layer Per laag + By Object Per object + Clear All Alles wissen + Filter Filteren + Groups Groepen + Hidden Verborgen + Images Afbeeldingen + Linked Files Gekoppelde bestanden + Locked Vergrendeld + Color Panel Options Opties voor kleurenpaneel + Filter Layers Panel Rows Rijen in het lagenpaneel filteren + Hide Template Sjabloon verbergen + Show Template Sjabloon tonen + Accent Color Accentkleur + Amber Amber + Coral Koraal + Violet Violet + Color Scheme Kleurschema + Modern Modern +@msg `{key}` must be an array of these names: {_1} `{key}` moet een matrix van deze namen zijn: {_1} + Mode Modus + Blur Mode Vervagingsmodus + Edge Only Alleen rand + Overlay Edge Rand bedekken + Path Length Padlengte +@msg the file's layers weren't read ({_1}): it comes in as its page, in one layer de lagen van het bestand zijn niet ingelezen ({_1}): het komt binnen als zijn pagina, in één laag +@msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part de lagen van het bestand zijn niet ingelezen uit de bewerkingsgegevens ({_1}): ze komen uit het PDF-gedeelte +@msg `mode` must be grayscale, rgb, hsb, cmyk or websafe `mode` moet grayscale, rgb, hsb, cmyk of websafe zijn +@msg the Color Picker is not open de kleurenkiezer is niet open +@msg the screen color sampler is already open de schermkleurkiezer is al open +@msg this system has no screen color sampler dit systeem heeft geen schermkleurkiezer +swatch [None] [Geen] +swatch [Registration] [Passers] +swatch White Wit +swatch Black Zwart +swatch Red Rood +swatch Orange Red Oranjerood +swatch Orange Oranje +swatch Amber Amber +swatch Yellow Geel +swatch Yellow Green Geelgroen +swatch Lime Limoen +swatch Green Groen +swatch Emerald Smaragd +swatch Teal Blauwgroen +swatch Aqua Aqua +swatch Cyan Green Cyaangroen +swatch Cyan Cyaan +swatch Azure Azuur +swatch Blue Blauw +swatch Indigo Indigo +swatch Violet Violet +swatch Purple Paars +swatch Magenta Magenta +swatch Rose Roos +swatch Pink Roze +swatch Brown Bruin +swatch Tan Geelbruin +swatch Coffee Koffie +swatch Chocolate Chocoladebruin +swatch Sand Zand +swatch Copper Koper +swatch Rust Roest +swatch Walnut Walnoot +swatch Espresso Espresso +swatch White, Black Wit, zwart +swatch Radial White, Black Radiaal wit, zwart +swatch Sunset Zonsondergang +swatch Ocean Oceaan +swatch Grays Grijstinten +swatch Brights Felle kleuren +swatch Bright Red Felrood +swatch Bright Yellow Felgeel +swatch Bright Green Felgroen +swatch Bright Blue Felblauw +swatch Bright Violet Felviolet + Automate Automatiseren + Include All Subdirectories Alle submappen opnemen + Override Action "Open" Commands Actieopdrachten "Openen" overschrijven + Override Action "Save" Commands Actieopdrachten "Opslaan" overschrijven + Override Action "Export" Commands Actieopdrachten "Exporteren" overschrijven + Stop on Errors Stoppen bij fouten + Save and Close Opslaan en sluiten + Play: Afspelen: + +# Strings main added after this catalog was written + Convert to Straight Lines Omzetten naar rechte lijnen + Corner Point Angle Threshold: Hoekdrempel voor hoekpunten: + A File per Object Eén bestand per object + Example: {name} Voorbeeld: {name} + Export Preset: Exportvoorinstelling: + Folders: Mappen: + Insert Invoegen + Nothing is selected. Select the art to export, then open this tab again. Er is niets geselecteerd. Selecteer de illustratie die je wilt exporteren en open dit tabblad opnieuw. + One File of the Selection Eén bestand van de selectie + Start at: Beginnen bij: + {n} objects selected {n} objecten geselecteerd + Delete Export Preset Exportvoorinstelling verwijderen + Export Presets Exportvoorinstellingen + Save Export Preset Exportvoorinstelling opslaan + Export Layer Laag exporteren + Export Selection Selectie exporteren + All in One Folder Alles in één map + By Size or Row Per grootte of rij + By Format Per indeling + By Artboard or Item Per tekenbord of item + By Item, Then Format Per item, dan per indeling + Document Name Documentnaam + Artboard or Item Name Naam van tekenbord of item + Number (####) Nummer (####) + Date (YYYYMMDD) Datum (YYYYMMDD) + Date (YYYY-MM-DD) Datum (YYYY-MM-DD) + Time (hhmmss) Tijd (hhmmss) + PDF Print PDF voor afdrukken + Words Longer Than: Woorden langer dan: + After First: Na eerste: + Before Last: Voor laatste: + Hyphen Limit: Afbreeklimiet: + Hyphenation Zone: Afbreekzone: + letters letters + hyphens afbreekstreepjes + Hyphenate Capitalized Words Woorden met een hoofdletter afbreken + Outline Object Object omtrekken + Update Selection Selectie bijwerken + Show Original Path Oorspronkelijk pad tonen +@msg check a layer to export vink een laag aan om te exporteren +@msg select the art to export first selecteer eerst de illustratie die je wilt exporteren +@msg no saved selection recalled geen opgeslagen selectie opgeroepen + Merge only with selection Alleen samenvoegen met selectie + Sketch Schets + Halftone Pattern… Rasterpatroon… + Halftone Pattern Rasterpatroon + Pattern Type Patroontype + Dot Punt + Export Type: Exporttype: + Movie Clip Filmclip +@msg name is empty naam is leeg +@msg no symbol named `{n}` geen symbool met de naam `{n}` diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 359227658..f5afab545 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -8,7 +8,7 @@ fn tags_map_to_languages() { assert_eq!(lang_from_tag("en_US.UTF-8"), Some(Lang::EN)); assert_eq!(lang_from_tag("C"), Some(Lang::EN)); assert_eq!(lang_from_tag("POSIX"), Some(Lang::EN)); - assert_eq!(lang_from_tag("nl_NL"), None); + assert_eq!(lang_from_tag("qaa-QM"), None, "the private-use tag names no language"); // German: Germany, Austria, Switzerland and the rest share the one catalog. for tag in ["de", "de_DE.UTF-8", "de-AT", "de_CH", "de-LU", "de_DE.UTF-8@euro"] { assert_eq!(lang_from_tag(tag), Some(de()), "{tag}"); @@ -67,7 +67,7 @@ fn candidates_walk_from_specific_to_general() { #[test] fn os_language_lists_are_parsed() { assert_eq!(first_supported("(\n \"zh-Hant-TW\",\n \"en-US\"\n)\n"), Some(ZH())); - assert_eq!(first_supported("(\n \"nl-NL\",\n \"en-US\"\n)\n"), Some(Lang::EN)); + assert_eq!(first_supported("(\n \"qaa-QM\",\n \"en-US\"\n)\n"), Some(Lang::EN), "the private-use tag falls through to the next entry"); assert_eq!(first_supported("(\n \"de-DE\",\n \"en-US\"\n)\n"), Some(de())); assert_eq!(first_supported("(\n \"fr-FR\",\n \"en-US\"\n)\n"), Lang::from_code("fr")); assert_eq!(first_supported("("), None); From 2d0da1472fbd20b2717646fdf3d03eae09fd7662 Mon Sep 17 00:00:00 2001 From: Michael Gieson Date: Thu, 15 Oct 2026 22:25:45 -0400 Subject: [PATCH 004/208] Cursors: 36 more tool states get cursors of their own, listed in cursors.toml Node editing as the public manual describes it: Direct Selection's anchor squares (hollow or filled), Reshape Segment over a curved segment, Group Selection's plus (also Direct Selection with Alt), the arrow hollow while a drag snaps, and Alt-copy arrowheads. The Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows (ShearH, ShearV) are drawn in code. Lasso, the freehand, symbolism and other tools return cursors of their own that borrow their old look until drawn (Cursor::stand_in). crates/tools/src/cursors.toml lists every Cursor value, when it shows and its hotspot, for whoever draws them; `cargo xtask cursors` (in ci) warns about a missing, unknown, repeated or incomplete entry and never fails. The busy cursor is listed as missing (G1). Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 1 + CLAUDE.md | 1 + ROADMAP.md | 1 + crates/engine/src/tests_prefs.rs | 28 +- crates/engine/tests/pen_endpoint_picking.rs | 2 +- crates/tools/src/builder.rs | 2 +- crates/tools/src/cursors.toml | 665 ++++++++++++++++++++ crates/tools/src/direct.rs | 174 ++++- crates/tools/src/distort/puppet.rs | 3 +- crates/tools/src/draw2/anchor.rs | 40 +- crates/tools/src/draw2/curvature.rs | 14 +- crates/tools/src/draw2/gesture.rs | 11 +- crates/tools/src/extra.rs | 2 +- crates/tools/src/lib.rs | 215 ++++++- crates/tools/src/meshblend.rs | 2 +- crates/tools/src/pen.rs | 25 +- crates/tools/src/printtiling.rs | 2 +- crates/tools/src/select.rs | 37 +- crates/tools/src/symbolism.rs | 11 +- crates/tools/src/text.rs | 2 +- crates/tools/src/xform/free.rs | 25 +- crates/tools/src/xform/measure.rs | 2 +- crates/tools/src/xform/wand.rs | 4 +- crates/ui-egui/src/canvas.rs | 47 +- crates/ui-egui/src/cursors.rs | 288 +++++++-- docs/development.md | 4 + docs/gaps.md | 2 + docs/target-app-parity.md | 3 +- docs/ui-parity.md | 6 + xtask/src/brands.rs | 7 +- xtask/src/cursors.rs | 145 +++++ xtask/src/main.rs | 6 +- 32 files changed, 1683 insertions(+), 94 deletions(-) create mode 100644 crates/tools/src/cursors.toml create mode 100644 xtask/src/cursors.rs diff --git a/AGENTS.md b/AGENTS.md index 1731a4d39..0b6171cd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net. - Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method. - **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them. +- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works. - **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. - **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). diff --git a/CLAUDE.md b/CLAUDE.md index ca12182b7..899f22e39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net. - Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method. - **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them. +- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works. - **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. - **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). diff --git a/ROADMAP.md b/ROADMAP.md index 522d5ffc9..5d29ebd16 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| | 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5). +| 2026-10-15 | Cursors: 36 more tool states get cursors of their own. Node editing follows the public manual (Direct Selection's anchor squares, Reshape Segment, Group Selection's plus, the arrow hollow on a snap, the Alt-copy arrowheads), the Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows are drawn; Lasso, the freehand, symbolism and other tools borrow a drawn cursor until theirs is drawn. `crates/tools/src/cursors.toml` lists every cursor for whoever draws them, and `cargo xtask cursors` warns about one missing from it. Scores unchanged. | | 2026-10-16 | About 160 community PRs landed, each through the full gate. Crashes and hostile files: damaged PDF dictionaries and arrays no longer crash import (#2133, #2240, #2244, #2271), area type with huge row or column counts opens (#2109), hostile mesh edges are bounded (#2051). New: Dimension tool (#1788), legacy version 7 `.ai` save (#1794), Object › Intertwine (#2095), Apple Pencil and touch input on the web (#1740), Sample Color from Screen (#2257), Drop Shadow Darkness (#2252), Smart Blur edge modes (#2154), Scribble variations (#2180), Free Distort corners in its dialog (#2134), Rotate View presets (#2106), tritanopia proofing (#2234), Layers panel filters (#2087), Hide Template (#2117), File › Automate Batch through the Actions panel (#1938), dataset and graph CSV (#1929, #1941), Reset Tracking and Kerning (#2274), Alt+Enter copies in Transform fields (#2236), Shift+Tab panels (#2254), Control bar isolation, Recolor and image Replace (#2258, #2079, #2081). Revolve renders smooth with Cap on by default (#1193); Image Trace keeps tiny parts, thin outlines and shared corners, and Grayscale gradients are grey (#2012, #2025, #2263, #2264, #2265, #1879). Import: many `.ai`, EPS and PDF fidelity fixes (#2014–#2016, #2029, #2030, #2044, #2046, #2050–#2052, #2058, #2129, #2199, #2202, #2218), images up to 1 GB decoded (#2060), `.ai`/`.ait`/`.svgz` files paste from a file manager (#2148, #2201). Fixes across fields (arithmetic #2069, #2209, #2022; caret #1403), New Document (#2158, #2176), menus (#2232), shortcuts (#2235, #2146), Transform Each axes (#2227), Paste in Place (#1926), Color Picker tab order (#1937, #1939) and more. Languages: Bulgarian, Greek, Swedish and Finnish complete under the tests, pt-br completed. Details per PR in the PRs; headline numbers unchanged (estimated). | | 2026-10-14 | Brazilian Portuguese (`pt-br`) covers the whole interface and status/error messages under the completeness tests (#1914); Localization 71% → 74% (measured), hours 55–90 → 50–85. | | 2026-10-14 | Direct Selection: deleting an interior anchor of an open path leaves separate surviving runs. Delete/Backspace and history regressions cover the gap; explicit Remove Anchors remains connected. Headline numbers unchanged. | diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 1bcc2ec9b..e97d3bfa4 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -391,13 +391,22 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() { ("pen", Cursor::Pen), ("addAnchor", Cursor::PenAdd), ("deleteAnchor", Cursor::PenDelete), - ("anchorPoint", Cursor::Pen), - ("curvature", Cursor::Pen), - ("mesh", Cursor::PenAdd), + ("anchorPoint", Cursor::AnchorPoint), + ("curvature", Cursor::Curvature), + ("mesh", Cursor::MeshAdd), ("eyedropper", Cursor::Eyedropper), ("slice", Cursor::Slice), ("blend", Cursor::Blend), ("shapeBuilder", Cursor::ShapeBuilder), + ("paintbrush", Cursor::Paintbrush), + ("blobBrush", Cursor::BlobBrush), + ("shaper", Cursor::Shaper), + ("pencil", Cursor::Pencil), + ("smooth", Cursor::Smooth), + ("pathEraser", Cursor::PathEraser), + ("join", Cursor::Join), + ("eraser", Cursor::Eraser), + ("knife", Cursor::Knife), ]; for (tool, c) in own { assert_eq!(cursor(&mut s, tool), c, "{tool} off"); @@ -415,15 +424,6 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() { "polygon", "star", "flare", - "paintbrush", - "blobBrush", - "shaper", - "pencil", - "smooth", - "pathEraser", - "join", - "eraser", - "knife", "mirrorCut", "lineCut", "rectCut", @@ -440,7 +440,7 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() { assert_eq!(cursor(&mut s, tool), Cursor::Crosshair, "{tool} on"); } assert_eq!(cursor(&mut s, "type"), Cursor::Text); - assert_eq!(cursor(&mut s, "scissors"), Cursor::NotAllowed); + assert_eq!(cursor(&mut s, "scissors"), Cursor::ScissorsNotAllowed); s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": false})).unwrap(); for (tool, c) in own { assert_eq!(cursor(&mut s, tool), c, "{tool} off again"); @@ -467,6 +467,8 @@ fn precise_cursors_replace_every_pen_state() { Cursor::BlendAnchor, Cursor::ShapeBuilder, Cursor::ShapeBuilderErase, + Cursor::AnchorPoint, + Cursor::Curvature, ] { assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}"); } diff --git a/crates/engine/tests/pen_endpoint_picking.rs b/crates/engine/tests/pen_endpoint_picking.rs index 568318edc..cfba10538 100644 --- a/crates/engine/tests/pen_endpoint_picking.rs +++ b/crates/engine/tests/pen_endpoint_picking.rs @@ -102,7 +102,7 @@ fn alt_at_another_endpoint_reports_conversion_and_preserves_objects_through_undo assert_eq!(node(&s, b), &before_b); assert_eq!(s.doc().unwrap().history.undo.len(), before_undo + 1); // Check the captured cursor last: a mismatch must not hide the actual conversion/history controls. - assert_eq!(advertised, Cursor::PenConvert, "Alt cursor must describe the conversion actually performed, not Join"); + assert_eq!(advertised, Cursor::AnchorPoint, "Alt cursor must describe the conversion actually performed, not Join"); } #[test] diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 6286e1c1f..01e460136 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -705,7 +705,7 @@ impl Tool for LivePaintBucketTool { } fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Crosshair + Cursor::LivePaintBucket } } diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml new file mode 100644 index 000000000..a056578ec --- /dev/null +++ b/crates/tools/src/cursors.toml @@ -0,0 +1,665 @@ +# The cursors VectorCraft needs: one [[cursor]] per `Cursor` value (crates/tools/src/lib.rs). +# +# This is the source of truth for what each cursor is for, written for whoever draws them: +# designers never decide what a cursor is for, this file does. `cargo xtask cursors` warns about +# a `Cursor` value without an entry. Adding a cursor: CLAUDE.md › Adding a cursor. +# +# app the app's name as designers read it +# id the `Cursor` variant's name (PenAdd), which names its drawing +# group the heading it's listed under for designers +# shown_when every situation that shows it, one plain sentence each +# hotspot where the click lands +# look optional: only what the drawing must mean or match (a badge, a sibling cursor). +# Never describe another app's cursors; describe what ours must say. +# designer optional, default true: false for a cursor the operating system draws on purpose +# (nothing to draw) +# +# Wording: plain sentences for a designer, behaviour only. `cargo xtask brands` rejects vendor +# names here. + +app = "VectorCraft" + +# ── Selection ───────────────────────────────────────────────────────────────────────────────────── + +[[cursor]] +id = "Arrow" +group = "Selection" +shown_when = [ + "The Selection tool over empty canvas or over an unselected object (no Alt).", + "Dragging with the Selection tool (a move), or with Direct Selection (an anchor, a segment or a handle).", + "Any tool with no cursor of its own (Touch Type, Rotate View).", + "The resting state of Live Paint Selection, Reshape (away from a path), Free Transform, Puppet Warp and Crop Image (nothing selected or outside the box), the Perspective Grid (locked or away from handles) and Perspective Selection.", +] +hotspot = "The arrow's tip." +look = "The default pointer: a solid arrow. ArrowHollow is its hollow twin." + +[[cursor]] +id = "ArrowHollow" +group = "Selection" +shown_when = [ + "Direct Selection at rest: away from anchors and curved segments, and over handles and fills.", + "The pen-family tools with Cmd/Ctrl held (which lends Direct Selection).", + "While a Selection or Direct Selection drag snaps to a point or a guide (the solid arrow turns hollow).", +] +hotspot = "The arrow's tip." +look = "The same pointer as Arrow, hollow: white with a dark outline." + +[[cursor]] +id = "DirectAnchor" +group = "Selection" +shown_when = ["Direct Selection over an anchor of a path that isn't selected."] +hotspot = "The arrow's tip." +look = "ArrowHollow with a small hollow square badge (an unselected anchor)." + +[[cursor]] +id = "DirectAnchorSelected" +group = "Selection" +shown_when = ["Direct Selection over an anchor of a selected path."] +hotspot = "The arrow's tip." +look = "ArrowHollow with a small filled square badge (a selected anchor)." + +[[cursor]] +id = "ReshapeSegment" +group = "Selection" +shown_when = [ + "Direct Selection over a curved segment of a path that isn't selected as a whole.", + "The Anchor Point tool over a segment, and while it reshapes one.", + "The Pen with Alt held over a segment of a selected path, and while it reshapes one.", +] +hotspot = "The arrowhead's tip." +look = "Says \"a drag bends this segment\": an arrowhead with a small curve between two anchor marks." + +[[cursor]] +id = "GroupSelection" +group = "Selection" +shown_when = [ + "The Group Selection tool: each click selects the next enclosing group.", + "Direct Selection with Alt held (Alt makes it Group Selection for the next click).", +] +hotspot = "The arrow's tip." +look = "ArrowHollow with a plus badge." + +[[cursor]] +id = "SelectionCopy" +group = "Selection" +shown_when = [ + "Alt-dragging a copy with the Selection tool (the drag leaves the original behind).", + "Alt-dragging a copy with Direct Selection, dragging a path whole (by its fill, or by an anchor of a path selected whole).", +] +hotspot = "The front arrowhead's tip." +look = "Says \"a copy\": a double arrowhead, one in front of the other." + +[[cursor]] +id = "Move" +group = "Selection" +shown_when = [ + "The Selection tool over a selected object, or over any object with Alt held (once an Alt-drag starts, SelectionCopy shows).", + "Reshape over a path or while dragging.", + "Inside the Free Transform and Crop Image boxes.", + "Puppet Warp over a pin.", + "The Mesh tool over a mesh point or handle.", + "The Artboard tool inside an artboard.", + "The Gradient tool over the annotator's handles and during most gradient drags.", + "The Perspective Grid and Perspective Selection over plane widgets and handles.", +] +hotspot = "The centre." +look = "A four-way move cursor." + +[[cursor]] +id = "CornerRadius" +group = "Selection" +shown_when = ["Selection and Direct Selection (not Group Selection) over a Live Corners widget, and while dragging one (rounding the corners)."] +hotspot = "The arrow's tip." +look = "ArrowHollow with a rounded-corner badge." + +[[cursor]] +id = "PathBracket" +group = "Selection" +shown_when = ["Selection and Direct Selection over a bracket of selected type on a path, or dragging one (the type slides along its path)."] +hotspot = "The arrow's tip." +look = "Arrow with a bracket badge." + +[[cursor]] +id = "TypeWidget" +group = "Selection" +shown_when = ["The Selection tool over the type widget of selected type (a double-click converts point type to area type and back)."] +hotspot = "The arrow's tip." +look = "Arrow with a type badge (a letter T)." + +# ── Pen family ──────────────────────────────────────────────────────────────────────────────────── + +[[cursor]] +id = "Pen" +group = "Pen" +shown_when = [ + "The Pen tool where a click starts a new path.", + "The Curvature tool away from its points.", + "With Use Precise Cursors on, the crosshair shows instead.", +] +hotspot = "The nib's tip." +look = "A pen nib. Every Pen-family cursor is this nib with a different badge." + +[[cursor]] +id = "PenDrawing" +group = "Pen" +shown_when = [ + "The Pen tool while a path is being drawn, where a click adds the path's next point.", + "The close, join and convert badges still show over the points they act on.", +] +hotspot = "The nib's tip." +look = "The Pen nib with no badge." + +[[cursor]] +id = "PenAdd" +group = "Pen" +shown_when = [ + "The Pen over a segment of a selected path with Auto Add/Delete on (Shift off), or over a selected blend's spine.", + "The Add Anchor Point tool everywhere (and the Delete Anchor Point tool with Alt).", + "The Curvature tool over a segment.", + "Stands in for MeshAdd and PuppetPin until they're drawn.", +] +hotspot = "The nib's tip." +look = "The Pen nib with a plus badge." + +[[cursor]] +id = "PenDelete" +group = "Pen" +shown_when = [ + "The Pen over an anchor of a selected path (Auto Add/Delete), or over a blend-spine point.", + "The Delete Anchor Point tool everywhere (and the Add Anchor Point tool with Alt).", + "The Mesh tool with Alt over a mesh point.", +] +hotspot = "The nib's tip." +look = "The Pen nib with a minus badge." + +[[cursor]] +id = "PenClose" +group = "Pen" +shown_when = [ + "The Pen while drawing, over the first anchor of the path (a click closes it).", + "The Curvature tool over its first point (three points or more), or near the other end of the current open path.", +] +hotspot = "The nib's tip." +look = "The Pen nib with a ring badge (closing the loop)." + +[[cursor]] +id = "PenContinue" +group = "Pen" +shown_when = [ + "The Pen (not drawing) over an end of an open path (a click continues it).", + "The Curvature tool over an open end.", +] +hotspot = "The nib's tip." +look = "The Pen nib with a slash badge." + +[[cursor]] +id = "PenJoin" +group = "Pen" +shown_when = ["The Pen while drawing, over an end of another open path (a click joins the two)."] +hotspot = "The nib's tip." +look = "The Pen nib with a badge showing a line running into an end point." + +[[cursor]] +id = "PenConvert" +group = "Pen" +shown_when = ["The Pen while drawing, over the last anchor (a click retracts its outgoing handle). With Alt the Pen shows AnchorPoint and ReshapeSegment instead."] +hotspot = "The nib's tip." +look = "The Pen nib with a caret badge (like the AnchorPoint caret)." + +[[cursor]] +id = "AnchorPoint" +group = "Pen" +shown_when = [ + "The Anchor Point tool, except over a segment (ReshapeSegment shows there).", + "The Pen with Alt held, over an anchor or a handle end of a selected path.", +] +hotspot = "The anchor mark at the caret's apex." +look = "A caret (an upside-down V) with a small anchor square at its apex." + +[[cursor]] +id = "Curvature" +group = "Pen" +shown_when = [ + "The Curvature tool over empty canvas, or anywhere while drawing a path away from its points.", + "Elsewhere the Curvature tool borrows Pen-family cursors: PenAdd over a segment, Move over an anchor, PenContinue at an open end, PenConvert with Alt, PenClose over the first point of the path being drawn.", +] +hotspot = "The nib's tip." +look = "The Pen nib with a curve badge." + +# ── Type ────────────────────────────────────────────────────────────────────────────────────────── + +[[cursor]] +id = "Text" +group = "Type" +shown_when = [ + "Every horizontal type tool: Type, Area Type and Type on a Path (the Area and Path ones show the crosshair over a suitable path).", + "Stands in for TypeVertical until it's drawn.", +] +hotspot = "The I-beam's centre." +look = "An I-beam with a short crossbar marking the baseline." + +[[cursor]] +id = "TypeVertical" +group = "Type" +shown_when = ["The vertical type tools: Vertical Type, Vertical Area Type and Vertical Type on a Path (the Area and Path ones show the crosshair over a suitable path)."] +hotspot = "The I-beam's centre." +look = "The Text I-beam turned a quarter turn." + +# ── Transform and resize ────────────────────────────────────────────────────────────────────────── + +[[cursor]] +id = "Crosshair" +group = "Transform and resize" +shown_when = [ + "The generic drawing cursor: all shape, line, grid, flare, cut, graph and liquify tools, and Place.", + "The Rotate, Reflect, Scale and Shear tools with a selection (Reflect, Scale and Shear even mid-drag).", + "The Area Type and Type on a Path tools over a suitable path.", + "The Artboard tool outside every artboard; the Gradient tool away from the annotator; Free Transform corners in Distort or Perspective mode.", + "With Use Precise Cursors on, it replaces the Pen, Eyedropper, Slice, Blend and Shape Builder cursors.", + "Stands in for every drawing tool cursor not drawn yet.", +] +hotspot = "The centre of the open gap." +look = "Four arms around an open centre, no dot, so the art under the hotspot stays visible." + +[[cursor]] +id = "ResizeH" +group = "Transform and resize" +shown_when = [ + "The left and right handles of the bounding box (or whichever handle reads as left/right once the box is rotated) with Selection, Free Transform, Artboard, Slice Selection and Crop Image.", + "Over or dragging a vertical ruler guide.", + "Perspective Grid extent handles and near-horizontal Perspective Selection handles.", + "The freeform gradient's spread ring.", +] +hotspot = "The centre." +look = "A horizontal double arrow. ResizeV, ResizeNwSe and ResizeNeSw are the same arrow turned." + +[[cursor]] +id = "ResizeV" +group = "Transform and resize" +shown_when = [ + "The top and bottom handles of the same boxes as ResizeH.", + "Over or dragging a horizontal ruler guide.", + "The Perspective Grid's horizon, height and cell-size handles, and near-vertical Perspective Selection handles.", +] +hotspot = "The centre." +look = "The ResizeH arrow, vertical." + +[[cursor]] +id = "ResizeNwSe" +group = "Transform and resize" +shown_when = [ + "The top-left and bottom-right corner handles of the same boxes as ResizeH (rotation-adjusted).", + "Perspective Selection handles at 112.5° to 157.5°.", +] +hotspot = "The centre." +look = "The ResizeH arrow, diagonal from top-left to bottom-right." + +[[cursor]] +id = "ResizeNeSw" +group = "Transform and resize" +shown_when = [ + "The top-right and bottom-left corner handles of the same boxes as ResizeH (rotation-adjusted).", + "Perspective Selection handles at 22.5° to 67.5°.", +] +hotspot = "The centre." +look = "The ResizeH arrow, diagonal from bottom-left to top-right." + +[[cursor]] +id = "ShearH" +group = "Transform and resize" +shown_when = [ + "Free Transform with Cmd/Ctrl held over the top or bottom handle of the bounding box, and while that drag shears the object sideways.", + "Without Cmd/Ctrl the same handles show ResizeV.", +] +hotspot = "The centre, between the two arrows." +look = "Two single arrows side by side, one above the other, pointing opposite ways (left and right): one side sliding past the other." + +[[cursor]] +id = "ShearV" +group = "Transform and resize" +shown_when = [ + "Free Transform with Cmd/Ctrl held over the left or right handle of the bounding box, and while that drag shears the object up or down.", + "Without Cmd/Ctrl the same handles show ResizeH.", +] +hotspot = "The centre, between the two arrows." +look = "The ShearH arrows turned upright." + +[[cursor]] +id = "Rotate" +group = "Transform and resize" +shown_when = [ + "The Rotate tool while dragging.", + "The Gradient tool over its rotate zone, or rotating.", + "Puppet Warp with Alt near a selected pin.", + "The bounding-box rotate cursor when the pointer is exactly on the box's centre.", +] +hotspot = "Below the arc, in its empty middle." +look = "An arc with an arrowhead at each end. RotateToward is the same arc, turned." + +[[cursor]] +id = "RotateToward" +group = "Transform and resize" +shown_when = ["Selection and Free Transform in the rotate zone just outside a bounding-box corner, and while rotating there. The Rotate, Gradient and Puppet Warp rotate cursors don't turn."] +hotspot = "Below the arc, in its empty middle, as for Rotate." +look = "The Rotate arc drawn for the top-right corner (direction 1). VectorCraft turns it in 45° steps so it wraps whichever corner the pointer is at." + +# ── Navigation ──────────────────────────────────────────────────────────────────────────────────── + +[[cursor]] +id = "Hand" +group = "Navigation" +shown_when = ["The Hand tool, or Space held with any tool (not with Cmd/Ctrl): ready to pan."] +hotspot = "The middle of the hand." +look = "An open hand. HandGrab is the same hand closed." + +[[cursor]] +id = "HandGrab" +group = "Navigation" +shown_when = ["While panning: a Hand tool drag, a Space drag, or a middle-button drag with any tool."] +hotspot = "The middle of the hand, as for Hand." +look = "The Hand, closed (grabbing)." + +[[cursor]] +id = "ZoomIn" +group = "Navigation" +shown_when = ["The Zoom tool, or Cmd/Ctrl+Space with any tool: a click zooms in."] +hotspot = "The lens centre." +look = "A magnifier with a plus in its lens." + +[[cursor]] +id = "ZoomOut" +group = "Navigation" +shown_when = ["The Zoom tool, or Cmd/Ctrl+Space, with Alt held: a click zooms out."] +hotspot = "The lens centre." +look = "The ZoomIn magnifier with a minus in its lens." + +[[cursor]] +id = "NotAllowed" +group = "Navigation" +shown_when = [ + "The Rotate, Reflect, Scale and Shear tools with nothing selected.", + "Puppet Warp off the mesh, where no pin can go.", +] +hotspot = "The centre." +designer = false + +# ── Colour and gradient ─────────────────────────────────────────────────────────────────────────── + +[[cursor]] +id = "Eyedropper" +group = "Colour and gradient" +shown_when = ["The Eyedropper tool, whatever the modifier keys. With Use Precise Cursors on, the crosshair shows instead."] +hotspot = "The pipette's tip." +look = "A pipette." + +[[cursor]] +id = "AddStop" +group = "Colour and gradient" +shown_when = [ + "The Gradient tool over the annotator's bar (a click adds a stop).", + "The freeform gradient over its line or the art.", +] +hotspot = "The arrow's tip." +look = "Arrow with a plus badge. RemoveStop is the same with a minus." + +[[cursor]] +id = "RemoveStop" +group = "Colour and gradient" +shown_when = [ + "The Gradient tool while a stop is dragged off the bar (releasing deletes it).", + "The freeform gradient while a point is dragged out.", +] +hotspot = "The arrow's tip." +look = "Arrow with a minus badge." + +[[cursor]] +id = "MeshAdd" +group = "Colour and gradient" +shown_when = [ + "The Mesh tool over a mesh or a path, off a mesh point: a click adds a mesh point (turning a path into a mesh).", + "Elsewhere the Mesh tool shows PenDelete (Alt over a mesh point) and Move (over a point or handle).", +] +hotspot = "Where the mesh point goes." + +[[cursor]] +id = "LivePaintBucket" +group = "Colour and gradient" +shown_when = ["The Live Paint Bucket: a click fills a face (or an edge) of a Live Paint group."] +hotspot = "Where the paint pours out." + +# ── Width, blend, slice and shape builder ───────────────────────────────────────────────────────── + +[[cursor]] +id = "Width" +group = "Width, blend, slice and shape builder" +shown_when = ["The Width tool away from strokes."] +hotspot = "The arrow's tip." +look = "ArrowHollow with a lens badge (a stroke that swells in the middle). WidthAdd and WidthPoint build on it." + +[[cursor]] +id = "WidthAdd" +group = "Width, blend, slice and shape builder" +shown_when = ["The Width tool over a stroke, away from its width points (a drag adds one)."] +hotspot = "The arrow's tip." +look = "The Width cursor with a plus." + +[[cursor]] +id = "WidthPoint" +group = "Width, blend, slice and shape builder" +shown_when = ["The Width tool over a width point or a handle end, and during any Width drag."] +hotspot = "The arrow's tip." +look = "The Width cursor with a mark across the swell (the width being dragged)." + +[[cursor]] +id = "Blend" +group = "Width, blend, slice and shape builder" +shown_when = ["The Blend tool away from art. With Use Precise Cursors on, the crosshair shows instead."] +hotspot = "The crosshair's centre." +look = "Crosshair with a hollow square badge. BlendObject and BlendAnchor change the badge." + +[[cursor]] +id = "BlendObject" +group = "Width, blend, slice and shape builder" +shown_when = ["The Blend tool over an object it can blend (away from its anchors)."] +hotspot = "The crosshair's centre." +look = "Crosshair with a filled square badge." + +[[cursor]] +id = "BlendAnchor" +group = "Width, blend, slice and shape builder" +shown_when = ["The Blend tool over an anchor point (the blend starts there)."] +hotspot = "The crosshair's centre." +look = "Crosshair with a target badge (a ring with a centre dot)." + +[[cursor]] +id = "Slice" +group = "Width, blend, slice and shape builder" +shown_when = ["The Slice tool, always. With Use Precise Cursors on, the plain crosshair shows instead."] +hotspot = "The crosshair's centre." +look = "Crosshair with a blade badge." + +[[cursor]] +id = "SliceSelect" +group = "Width, blend, slice and shape builder" +shown_when = ["The Slice Selection tool away from the selected slice's handles."] +hotspot = "The arrow's tip." +look = "Arrow with a small slice badge (a rectangle cut by a line)." + +[[cursor]] +id = "ShapeBuilder" +group = "Width, blend, slice and shape builder" +shown_when = ["The Shape Builder tool (merge mode), and during a drag that started without Alt."] +hotspot = "The crosshair's centre." +look = "Crosshair with a plus badge. ShapeBuilderErase is the same with a minus." + +[[cursor]] +id = "ShapeBuilderErase" +group = "Width, blend, slice and shape builder" +shown_when = ["The Shape Builder with Alt held (erase mode), and during a drag that started with Alt."] +hotspot = "The crosshair's centre." +look = "Crosshair with a minus badge." + +# ── Drawing and editing tools ───────────────────────────────────────────────────────────────────── + +[[cursor]] +id = "Pencil" +group = "Drawing and editing tools" +shown_when = ["The Pencil tool: draws a freeform path; starting near the end of a selected open path continues it; Alt while drawing closes the path."] +hotspot = "The lead tip." + +[[cursor]] +id = "Paintbrush" +group = "Drawing and editing tools" +shown_when = ["The Paintbrush tool: draws a brushed freeform path (with pen pressure), continuing a selected path from its end; Alt closes it."] +hotspot = "The bristle tip." + +[[cursor]] +id = "BlobBrush" +group = "Drawing and editing tools" +shown_when = ["The Blob Brush tool: paints filled shapes that merge with touching ones of the same colour. No brush-size outline shows while hovering."] +hotspot = "The brush centre." + +[[cursor]] +id = "Eraser" +group = "Drawing and editing tools" +shown_when = ["The Eraser tool: a drag erases the area it covers. No size outline shows while hovering."] +hotspot = "The eraser's centre." + +[[cursor]] +id = "Smooth" +group = "Drawing and editing tools" +shown_when = ["The Smooth tool: a drag along a selected path smooths it."] +hotspot = "The tip." + +[[cursor]] +id = "PathEraser" +group = "Drawing and editing tools" +shown_when = ["The Path Eraser tool: a drag along a selected path erases that part of it."] +hotspot = "The tip." + +[[cursor]] +id = "Join" +group = "Drawing and editing tools" +shown_when = ["The Join tool: scrubbing across the gap or overlap between open path ends joins them."] +hotspot = "The tip." + +[[cursor]] +id = "Knife" +group = "Drawing and editing tools" +shown_when = ["The Knife tool: a freehand drag cuts objects along the line."] +hotspot = "The blade's point." + +[[cursor]] +id = "Scissors" +group = "Drawing and editing tools" +shown_when = ["The Scissors tool over a path segment: a click splits the path there."] +hotspot = "Where the blades cross, exact enough to cut where aimed." +look = "Scissors. ScissorsNotAllowed is this drawing with a badge." + +[[cursor]] +id = "ScissorsNotAllowed" +group = "Drawing and editing tools" +shown_when = ["The Scissors tool away from every path segment: a click does nothing."] +hotspot = "The same point as Scissors." +look = "The Scissors with a small not-allowed badge, so the cursor keeps its size and shape and only the badge says a click won't cut." + +[[cursor]] +id = "Shaper" +group = "Drawing and editing tools" +shown_when = ["The Shaper tool: a rough drawn shape becomes a clean live shape (rectangle, ellipse, polygon, line); scribbling over art deletes it."] +hotspot = "The tip." + +[[cursor]] +id = "Lasso" +group = "Drawing and editing tools" +shown_when = ["The Lasso tool: drag a loop round anchor points and segments to select them; Shift adds to the selection, Alt takes from it."] +hotspot = "Where the loop is drawn from." + +[[cursor]] +id = "MagicWand" +group = "Drawing and editing tools" +shown_when = ["The Magic Wand tool: a click selects objects like the one clicked (per the Magic Wand options); Shift adds, Alt subtracts."] +hotspot = "The wand's tip." + +[[cursor]] +id = "PuppetPin" +group = "Drawing and editing tools" +shown_when = [ + "Puppet Warp over the selection's mesh where a pin can go: a click adds a pin.", + "Elsewhere Puppet Warp shows NotAllowed (off the mesh), Move (over a pin) and Rotate (Alt near a selected pin).", +] +hotspot = "Where the pin goes." + +[[cursor]] +id = "Measure" +group = "Drawing and editing tools" +shown_when = ["The Measure tool: drag to measure distance and angle."] +hotspot = "Exact, because precision matters here (an open centre suits it)." + +[[cursor]] +id = "PrintTiling" +group = "Drawing and editing tools" +shown_when = ["The Print Tiling tool: a drag moves the printable page area over the artboard (it snaps to the artboard's edges)."] +hotspot = "The page corner being placed." + +# ── Symbolism tools ─────────────────────────────────────────────────────────────────────────────── +# The brush size is drawn by the app as a circle around the cursor. + +[[cursor]] +id = "SymbolSprayer" +group = "Symbolism tools" +shown_when = ["The Symbol Sprayer: a drag sprays instances of the current symbol; Alt reverses it."] +hotspot = "The spray centre." + +[[cursor]] +id = "SymbolShifter" +group = "Symbolism tools" +shown_when = ["The Symbol Shifter: a drag pushes symbol instances along; Alt reverses it."] +hotspot = "The brush centre." + +[[cursor]] +id = "SymbolScruncher" +group = "Symbolism tools" +shown_when = ["The Symbol Scruncher: a drag pulls instances together; Alt reverses it."] +hotspot = "The brush centre." + +[[cursor]] +id = "SymbolSizer" +group = "Symbolism tools" +shown_when = ["The Symbol Sizer: a drag enlarges instances; Alt reverses it."] +hotspot = "The brush centre." + +[[cursor]] +id = "SymbolSpinner" +group = "Symbolism tools" +shown_when = ["The Symbol Spinner: a drag rotates instances in its direction; Alt reverses it."] +hotspot = "The brush centre." + +[[cursor]] +id = "SymbolStainer" +group = "Symbolism tools" +shown_when = ["The Symbol Stainer: a drag tints instances toward the fill colour; Alt reverses it."] +hotspot = "The brush centre." + +[[cursor]] +id = "SymbolScreener" +group = "Symbolism tools" +shown_when = ["The Symbol Screener: a drag makes instances more transparent; Alt reverses it."] +hotspot = "The brush centre." + +[[cursor]] +id = "SymbolStyler" +group = "Symbolism tools" +shown_when = ["The Symbol Styler: a drag applies the chosen graphic style to instances; Alt reverses it."] +hotspot = "The brush centre." + +# ── Not needed ──────────────────────────────────────────────────────────────────────────────────── +# Tools that deliberately have no cursor of their own (listed for designers so nobody draws one). + +[[not_needed]] +what = "Liquify tools (Warp, Twirl, Pucker, Bloat, Scallop, Crystallize, Wrinkle)" +why = "They show the Crosshair, and the app draws the brush's real size and tilt as an outline; a fixed-size cursor would disagree with it." + +[[not_needed]] +what = "Shape, line, grid, flare, graph, cut and gradient tools" +why = "The generic Crosshair is the intended drawing cursor for them." diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 27edc8eaf..f45434f4c 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -33,7 +33,7 @@ use std::borrow::Cow; use serde_json::{Value, json}; use vectorcraft_doc::hit::{HitKind, hit_test}; use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection}; -use vectorcraft_geom::{Anchor, PathData, Point, Rect}; +use vectorcraft_geom::{Anchor, PathData, Point, Rect, Vec2}; use crate::bbox::move_delta; use crate::corners::{self, CornerDrag, over_widget}; @@ -122,6 +122,16 @@ enum State { }, } +/// How far (pt) a snap must move a dragged point for the arrow to say it snapped. +const SNAPPED: f64 = 1e-9; + +/// Whether a drag step snapped: the snapped move `d` left where the pointer alone put it +/// (`unsnapped`). With Shift the constraint (General › Constrain Angle) moves it too, so only a +/// snap's guide tells then. +fn snapped(d: Vec2, unsnapped: Vec2, shift: bool, guides: &[Overlay]) -> bool { + if shift { !guides.is_empty() } else { (d - unsnapped).hypot() > SNAPPED } +} + pub struct DirectSelectionTool { group: bool, state: State, @@ -139,6 +149,8 @@ pub struct DirectSelectionTool { handle_snap: HandleSnap, /// What the objects being moved snap to, gathered when the move begins. move_snap: Option, + /// The drag snapped to a point or a guide at its last step: the arrow turns hollow. + snapped: bool, /// The anchor under the pointer (Highlight anchors on mouse over): its path, where it is. hover: Option<(NodeId, Point)>, guide: GuideEdit, @@ -161,6 +173,7 @@ impl DirectSelectionTool { copy_snap: None, handle_snap: HandleSnap::default(), move_snap: None, + snapped: false, hover: None, guide: GuideEdit::default(), click: None, @@ -466,6 +479,7 @@ impl Tool for DirectSelectionTool { return vec![Action::Begin("Reshape Spine".into())]; } self.spine = None; + self.snapped = false; if let Some(g) = self.mesh.hit(cx, &cx.selection.objects, p) { self.state = State::Mesh; return self.mesh.press(g); @@ -574,6 +588,7 @@ impl Tool for DirectSelectionTool { self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); } let mut d = move_delta(start, p, ev.mods.shift); + let unsnapped = d; // The grabbed anchor snaps and the others follow it. Shift keeps the move at its // angle, and the anchor slides along it onto what Smart Guides find there (#886). self.guides.clear(); @@ -582,6 +597,7 @@ impl Tool for DirectSelectionTool { let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref()); (d, self.guides) = (q - grab, guides); } + self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y}))); out } @@ -618,6 +634,7 @@ impl Tool for DirectSelectionTool { } } let mut d = move_delta(start, p, ev.mods.shift); + let unsnapped = d; self.guides.clear(); let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap }; if let (Some(grab), Some(snap)) = (grab, snap) { @@ -627,6 +644,7 @@ impl Tool for DirectSelectionTool { } else if let Some(snap) = &self.move_snap { (d, self.guides) = snap.snap(cx, start, d, ev.mods.alt); } + self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); out.push(Action::Preview( "object.transform".into(), json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(d)), "copy": ev.mods.alt}), @@ -648,6 +666,7 @@ impl Tool for DirectSelectionTool { } (PointerKind::Drag, State::Handle { id, si, ai, out }) => { let (q, guides) = self.handle_snap.snap(cx, (id, si, ai), p, ev.mods.shift); + self.snapped = snapped(q.to_vec2(), p.to_vec2(), ev.mods.shift, &guides); self.guides = guides; vec![Action::Preview( "path.setHandle".into(), @@ -803,15 +822,89 @@ impl Tool for DirectSelectionTool { } } } - fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { if !self.group && (matches!(self.state, State::Corner(_)) || over_widget(cx, p, true)) { return Cursor::CornerRadius; } if !self.group && (matches!(self.state, State::Bracket(_)) || over_bracket(cx, p)) { return Cursor::PathBracket; } + let rest = if self.group { Cursor::GroupSelection } else { Cursor::ArrowHollow }; + if !self.group { + match self.state { + // Alt-dragging leaves a copy behind. + State::MoveObject { began: true, .. } if m.alt => return Cursor::SelectionCopy, + // Bending a segment keeps the cursor that offered it. + State::Segment { began: true, .. } | State::SpineSegment { began: true, .. } => return Cursor::ReshapeSegment, + // A drag shows the filled arrow, hollow while it snaps to a point or a guide. + State::Handle { .. } + | State::SpineHandle { .. } + | State::SpinePoint { began: true, .. } + | State::TypeArea { began: true, .. } + | State::MoveAnchors { began: true, .. } + | State::MoveObject { began: true, .. } => { + return if self.snapped { Cursor::ArrowHollow } else { Cursor::Arrow }; + } + // Alt makes it the Group Selection tool for the next press, unless the press takes + // a handle, a mesh or spine point, a type frame's edge or a ruler guide. + State::Idle if m.alt => { + let other = self.takes_point(cx, p) + || hovered_anchor(cx, p).is_none() && (hit_frame_edge(cx, p, cx.pick_tol()).is_some() || self.guide.cursor(cx, p).is_some()); + if !other { + return Cursor::GroupSelection; + } + } + State::Idle => { + if let Some(c) = self.part_under(cx, p, m) { + return c; + } + } + _ => {} + } + } // A press on the highlighted anchor picks it, not the guide. - self.guide.cursor(cx, p).filter(|_| self.hover.is_none() || self.guide.busy()).unwrap_or(Cursor::ArrowHollow) + self.guide.cursor(cx, p).filter(|_| self.hover.is_none() || self.guide.busy()).unwrap_or(rest) + } +} + +impl DirectSelectionTool { + /// A press at `p` takes a mesh point or handle, a direction handle's end, the clicked spine + /// point's handle or a spine point, before any anchor or segment. + fn takes_point(&self, cx: &ToolContext, p: Point) -> bool { + let point = cx.point_tol(); + self.mesh.hit(cx, &cx.selection.objects, p).is_some() + || hit_handle(cx, p, point).is_some() + || self.spine.is_some_and(|sel| hit_spine_handle(cx, sel, p, point).is_some()) + || hit_spine_point(cx, p, point).is_some() + } + + /// The cursor for the path part a press at `p` takes, in the press's order: an anchor (its + /// square filled when its path is selected), then a curved segment a drag reshapes (a path's + /// not selected whole, or a selected blend's spine's). Handles, mesh and spine points, frame + /// edges, ruler guides, straight segments and fills keep the plain cursor. + fn part_under(&self, cx: &ToolContext, p: Point, m: Mods) -> Option { + let tol = cx.pick_tol(); + if self.takes_point(cx, p) { + return None; + } + // A selected path's anchors or the anchors of the path under the pointer, as Highlight + // anchors on mouse over finds them: walking every path on each hover is too slow. + if let Some((id, _)) = hovered_anchor(cx, p) { + return Some(if cx.selection.contains(id) { Cursor::DirectAnchorSelected } else { Cursor::DirectAnchor }); + } + if hit_frame_edge(cx, p, tol).is_some() { + return None; + } + if matches!(hit_spine_segment(cx, p, tol), Some((.., false))) { + return Some(Cursor::ReshapeSegment); + } + if m.shift || self.guide.cursor(cx, p).is_some() { + return None; + } + let h = hit_test(cx.doc, p, cx.hit_options())?; + let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none(); + let curved = matches!(h.kind, HitKind::Stroke | HitKind::Outline) && segment_at(cx, h.leaf, p).is_some_and(|s| s.curved); + (!whole && curved).then_some(Cursor::ReshapeSegment) } } @@ -1423,6 +1516,81 @@ mod tests { assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 102.5, 300.0)), vec![Action::Begin("Reshape".into())], "the handle"); } + /// Direct Selection's cursors from Illustrator's manual: the white arrow at rest; over an anchor + /// a square, hollow on an unselected path and filled on a selected one; the Reshape Segment + /// cursor over a curved segment a drag reshapes, not over a straight one (which moves) or a + /// path selected whole (which moves whole); Group Selection's plus with Alt and as its tool. + #[test] + fn hovering_shows_the_anchor_square_reshape_and_group_cursors() { + let (d, arch) = arch_doc(); + let p = paint(); + let m = Mods::default(); + let pt = Point::new; + let none = Selection::default(); + let c = cx(&d, &none, &p); + let t = DirectSelectionTool::new(false); + assert_eq!(t.cursor(&c, pt(20.0, 20.0), m), Cursor::ArrowHollow); + assert_eq!(t.cursor(&c, pt(100.0, 300.0), m), Cursor::DirectAnchor); + assert_eq!(t.cursor(&c, pt(150.0, 262.5), m), Cursor::ReshapeSegment); + assert_eq!(t.cursor(&c, pt(150.0, 100.0), m), Cursor::ArrowHollow, "a straight segment moves"); + assert_eq!(t.cursor(&c, pt(150.0, 150.0), m), Cursor::ArrowHollow, "the fill"); + let alt = Mods { alt: true, ..m }; + assert_eq!(t.cursor(&c, pt(100.0, 300.0), alt), Cursor::GroupSelection); + assert_eq!(DirectSelectionTool::new(true).cursor(&c, pt(100.0, 300.0), m), Cursor::GroupSelection); + let mut whole = Selection::default(); + whole.set([arch]); + let c = cx(&d, &whole, &p); + assert_eq!(t.cursor(&c, pt(200.0, 300.0), m), Cursor::DirectAnchorSelected); + assert_eq!(t.cursor(&c, pt(150.0, 262.5), m), Cursor::ArrowHollow, "a path selected whole moves whole"); + // One anchor selected: its handle end shows and keeps the plain arrow, Alt or not (a press + // takes the handle). + let s = anchors_of(arch, &[(0, 0)]); + let c = cx(&d, &s, &p); + assert_eq!(t.cursor(&c, pt(100.0, 250.0), m), Cursor::ArrowHollow, "a handle end"); + assert_eq!(t.cursor(&c, pt(100.0, 250.0), alt), Cursor::ArrowHollow, "a handle end with Alt"); + assert_eq!(t.cursor(&c, pt(200.0, 300.0), m), Cursor::DirectAnchorSelected); + // Bending the curve keeps the Reshape Segment cursor. + let mut t = DirectSelectionTool::new(false); + let c = cx(&d, &none, &p); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 262.5)); + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 150.0, 230.0)); + assert_eq!(t.cursor(&c, pt(150.0, 230.0), m), Cursor::ReshapeSegment); + } + + /// A drag shows the filled arrow, hollow while it snaps to a point; an Alt-drag copy shows the + /// double arrowhead. + #[test] + fn drags_show_the_filled_arrow_hollow_on_a_snap_and_alt_the_copy_cursor() { + let (d, _, b) = two_squares(); + let p = paint(); + let none = Mods::default(); + let at = |kind, x, y, m: Mods| PointerEvent::new(kind, x, y).with_mods(m); + let one = anchors_of(b, &[(0, 0)]); + let c = cx(&d, &one, &p); + let mut t = DirectSelectionTool::new(false); + t.pointer(&c, &at(PointerKind::Down, 300.0, 300.0, none)); + t.pointer(&c, &at(PointerKind::Drag, 250.0, 260.0, none)); + assert_eq!(t.cursor(&c, Point::new(250.0, 260.0), none), Cursor::Arrow); + // Onto A's bottom-right corner. + t.pointer(&c, &at(PointerKind::Drag, 203.0, 199.0, none)); + assert_eq!(t.cursor(&c, Point::new(203.0, 199.0), none), Cursor::ArrowHollow); + t.pointer(&c, &at(PointerKind::Up, 203.0, 199.0, none)); + // Shift with a Constrain Angle of 10°: the constraint alone isn't a snap. + let shift = Mods { shift: true, ..none }; + let tilted = ToolContext { constrain_angle: 10.0, ..cx(&d, &one, &p) }; + t.pointer(&tilted, &at(PointerKind::Down, 300.0, 300.0, shift)); + t.pointer(&tilted, &at(PointerKind::Drag, 380.0, 262.0, shift)); + assert_eq!(t.cursor(&tilted, Point::new(380.0, 262.0), shift), Cursor::Arrow); + t.pointer(&tilted, &at(PointerKind::Up, 380.0, 262.0, shift)); + let mut whole = Selection::default(); + whole.set([b]); + let c = cx(&d, &whole, &p); + let alt = Mods { alt: true, ..none }; + t.pointer(&c, &at(PointerKind::Down, 300.0, 300.0, alt)); + t.pointer(&c, &at(PointerKind::Drag, 250.0, 260.0, alt)); + assert_eq!(t.cursor(&c, Point::new(250.0, 260.0), alt), Cursor::SelectionCopy); + } + /// [`doc_with_rect`] (A, 100..200) and a second square B, 300..400: (A, B). fn two_squares() -> (vectorcraft_doc::Document, NodeId, NodeId) { let (mut d, a) = doc_with_rect(); diff --git a/crates/tools/src/distort/puppet.rs b/crates/tools/src/distort/puppet.rs index 648621ba5..9c4b11f91 100644 --- a/crates/tools/src/distort/puppet.rs +++ b/crates/tools/src/distort/puppet.rs @@ -475,7 +475,7 @@ impl Tool for PuppetWarpTool { } else if Self::pin_at(cx, &c.set, p).is_some() { Cursor::Move } else if c.set.rest_of(p).is_some() { - Cursor::PenAdd + Cursor::PuppetPin } else { // Off the mesh: no pin goes there. Cursor::NotAllowed @@ -733,6 +733,7 @@ mod tests { #[test] fn a_click_off_the_mesh_places_no_pin_and_says_so() { let mut r = Rig::new(); + assert_eq!(r.cursor(130.0, 170.0, Mods::default()), Cursor::PuppetPin, "on the mesh, away from the pins"); r.click(130.0, 170.0, Mods::default()); assert_eq!(r.cursor(400.0, 400.0, Mods::default()), Cursor::NotAllowed); assert!(r.click(400.0, 400.0, Mods::default()).is_empty()); diff --git a/crates/tools/src/draw2/anchor.rs b/crates/tools/src/draw2/anchor.rs index 56c54b476..05816d718 100644 --- a/crates/tools/src/draw2/anchor.rs +++ b/crates/tools/src/draw2/anchor.rs @@ -219,22 +219,56 @@ impl Tool for AnchorTool { "deleteAnchor" => Cursor::PenAdd, "scissors" => { if hit_segment(cx, p, cx.pick_tol()).is_some() { - Cursor::Crosshair + Cursor::Scissors } else { - Cursor::NotAllowed + Cursor::ScissorsNotAllowed } } - _ => Cursor::Pen, + _ => match self.state { + State::Reshape { .. } => Cursor::ReshapeSegment, + State::Idle => press_cursor(cx, p), + State::Convert { .. } | State::Handle { .. } => Cursor::AnchorPoint, + }, } } } +/// The Anchor Point tool's cursor for a press at `p`: the Reshape Segment cursor where it takes a +/// segment (a path's or a selected blend's spine's), else its caret. Handles and anchors win +/// over the segments through them, as the press has it. +pub(crate) fn press_cursor(cx: &ToolContext, p: Point) -> Cursor { + let (point, tol) = (cx.point_tol(), cx.pick_tol()); + let segment = hit_handle(cx, p, point).is_none() + && hit_anchor(cx, p, point).is_none() + && (hit_segment(cx, p, tol).is_some() || crate::direct::hit_spine_segment(cx, p, tol).is_some()); + if segment { Cursor::ReshapeSegment } else { Cursor::AnchorPoint } +} + #[cfg(test)] mod tests { use super::*; use crate::testutil::*; use vectorcraft_doc::Selection; + /// The Anchor Point tool has its own caret cursor, not the Pen's start-a-path one, and over a + /// segment the Reshape Segment cursor, kept while the segment is dragged. + #[test] + fn anchor_point_tool_shows_its_caret_and_reshape_over_segments() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = AnchorTool::new("anchorPoint"); + let none = Mods::default(); + for at in [Point::new(20.0, 20.0), Point::new(200.0, 100.0)] { + assert_eq!(t.cursor(&cx, at, none), Cursor::AnchorPoint, "{at:?}"); + } + assert_eq!(t.cursor(&cx, Point::new(150.0, 101.0), none), Cursor::ReshapeSegment); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 140.0)); + assert_eq!(t.cursor(&cx, Point::new(150.0, 140.0), none), Cursor::ReshapeSegment); + } + #[test] fn add_and_delete_anchor_clicks() { let (d, id) = doc_with_rect(); diff --git a/crates/tools/src/draw2/curvature.rs b/crates/tools/src/draw2/curvature.rs index 3806e6726..7394c467d 100644 --- a/crates/tools/src/draw2/curvature.rs +++ b/crates/tools/src/draw2/curvature.rs @@ -621,7 +621,7 @@ impl Tool for CurvatureTool { Some(0) if !self.closed && self.pts.len() >= 3 => Cursor::PenClose, Some(_) if m.alt => Cursor::PenConvert, Some(_) => Cursor::Move, - None => Cursor::Pen, + None => Cursor::Curvature, }; } let tol = point_tol(cx); @@ -642,7 +642,7 @@ impl Tool for CurvatureTool { if end.is_none() && segment_in(cx, editable_paths(cx), p, tol).is_some() { return Cursor::PenAdd; } - Cursor::Pen + Cursor::Curvature } } @@ -653,6 +653,16 @@ mod tests { use vectorcraft_doc::{Appearance, Document, Node, Selection}; use vectorcraft_geom::{ParamCurve, PathData}; + /// The Curvature tool has its own cursor, not the Pen's start-a-path one. + #[test] + fn curvature_tool_shows_its_cursor() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + assert_eq!(CurvatureTool::default().cursor(&cx, Point::new(10.0, 10.0), Mods::default()), Cursor::Curvature); + } + #[test] fn first_click_creates_then_notify_tracks_path() { let (d, id) = doc_with_rect(); diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 4aef19665..040fdc112 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -312,7 +312,16 @@ impl Tool for GestureTool { o } fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Crosshair + match self.id { + "paintbrush" => Cursor::Paintbrush, + "blobBrush" => Cursor::BlobBrush, + "eraser" => Cursor::Eraser, + "knife" => Cursor::Knife, + "smooth" => Cursor::Smooth, + "pathEraser" => Cursor::PathEraser, + "join" => Cursor::Join, + _ => Cursor::Pencil, + } } fn options(&self) -> Value { match self.id { diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index d637375ae..2c8720464 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -393,7 +393,7 @@ impl Tool for ShaperTool { vec![Overlay::Path { path: crate::draw2::polyline(&self.points), color: BLUE, width: 1.5, dashed: false }] } fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { - Cursor::Crosshair + Cursor::Shaper } } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 222af68ea..f52b8059b 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -427,12 +427,30 @@ pub enum Cursor { #[default] Arrow, ArrowHollow, + /// Direct Selection over an anchor of a path that isn't selected: the white arrow with a + /// hollow square. + DirectAnchor, + /// Direct Selection over an anchor of a selected path: the white arrow with a filled square. + DirectAnchorSelected, + /// Over a segment a drag reshapes (Direct Selection, the Anchor Point tool, the Pen with Alt): + /// an arrowhead with a small curve between two anchor marks. + ReshapeSegment, + /// The Group Selection tool, and Direct Selection with Alt held: the white arrow with a plus. + GroupSelection, + /// Alt-dragging a copy with the Selection or Direct Selection tool: a double arrowhead. + SelectionCopy, Move, Crosshair, ResizeH, ResizeV, ResizeNwSe, ResizeNeSw, + /// Free Transform with Cmd/Ctrl over the top or bottom side, where a drag shears the object + /// sideways: two arrows side by side, pointing opposite ways along that side. + ShearH, + /// Free Transform with Cmd/Ctrl over the left or right side, where a drag shears the object up + /// or down: the ShearH arrows turned upright. + ShearV, Rotate, /// The rotate cursor beside a bounding box, bowed toward the pointer's side of the box: off /// its centre in direction `0..8` (0 straight up, then clockwise in 45° steps). Its arc wraps @@ -449,10 +467,14 @@ pub enum Cursor { PenDelete, PenClose, PenContinue, + /// The Anchor Point tool, and the Pen with Alt held (which lends its gestures): a caret, its tip + /// on the anchor it converts. + AnchorPoint, + /// The Curvature tool: the pen nib with a curve badge. + Curvature, /// While drawing, over an end of another open path (a click joins the two). PenJoin, - /// Over the last anchor of the path being drawn (a click retracts its outgoing handle), or with - /// Alt held over a selected path's handle or anchor (the Anchor Point tool's gesture). + /// Over the last anchor of the path being drawn: a click retracts its outgoing handle. PenConvert, Text, Hand, @@ -490,6 +512,60 @@ pub enum Cursor { ShapeBuilder, /// ...or, with Alt held, a minus (erase mode). ShapeBuilderErase, + /// The Lasso tool. + Lasso, + /// The Magic Wand tool. + MagicWand, + /// The Pencil tool. + Pencil, + /// The Paintbrush tool. + Paintbrush, + /// The Blob Brush tool. + BlobBrush, + /// The Eraser tool. + Eraser, + /// The Smooth tool. + Smooth, + /// The Path Eraser tool. + PathEraser, + /// The Join tool. + Join, + /// The Knife tool. + Knife, + /// The Shaper tool. + Shaper, + /// The Live Paint Bucket. + LivePaintBucket, + /// The Measure tool. + Measure, + /// The Print Tiling tool. + PrintTiling, + /// The Symbol Sprayer tool. + SymbolSprayer, + /// The Symbol Shifter tool. + SymbolShifter, + /// The Symbol Scruncher tool. + SymbolScruncher, + /// The Symbol Sizer tool. + SymbolSizer, + /// The Symbol Spinner tool. + SymbolSpinner, + /// The Symbol Stainer tool. + SymbolStainer, + /// The Symbol Screener tool. + SymbolScreener, + /// The Symbol Styler tool. + SymbolStyler, + /// The vertical type tools: a turned I-beam. + TypeVertical, + /// The Scissors over a path segment (a click splits the path there)... + Scissors, + /// ...or away from every segment (a click does nothing). + ScissorsNotAllowed, + /// The Mesh tool off a mesh point: a click adds one. + MeshAdd, + /// Puppet Warp over the selection's mesh where a pin can go: a click adds one. + PuppetPin, } impl Cursor { @@ -506,7 +582,7 @@ impl Cursor { } /// General › Use Precise Cursors: the drawing tools' pointers (the Pen's in every state, the - /// Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot. + /// Anchor Point and Curvature tools', the Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot. pub fn precise(self) -> Self { match self { Cursor::Pen @@ -517,16 +593,79 @@ impl Cursor { | Cursor::PenContinue | Cursor::PenJoin | Cursor::PenConvert + | Cursor::AnchorPoint + | Cursor::Curvature | Cursor::Eyedropper | Cursor::Slice | Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor | Cursor::ShapeBuilder - | Cursor::ShapeBuilderErase => Cursor::Crosshair, + | Cursor::ShapeBuilderErase + | Cursor::Lasso + | Cursor::MagicWand + | Cursor::Pencil + | Cursor::Paintbrush + | Cursor::BlobBrush + | Cursor::Eraser + | Cursor::Smooth + | Cursor::PathEraser + | Cursor::Join + | Cursor::Knife + | Cursor::Shaper + | Cursor::LivePaintBucket + | Cursor::Measure + | Cursor::PrintTiling + | Cursor::SymbolSprayer + | Cursor::SymbolShifter + | Cursor::SymbolScruncher + | Cursor::SymbolSizer + | Cursor::SymbolSpinner + | Cursor::SymbolStainer + | Cursor::SymbolScreener + | Cursor::SymbolStyler + | Cursor::Scissors + | Cursor::MeshAdd + | Cursor::PuppetPin => Cursor::Crosshair, c => c, } } + + /// The cursor this one borrows until it's drawn: the generic crosshair for the + /// tools, the I-beam for vertical type, the Pen's add cursor for the Mesh tool and Puppet + /// Warp, not-allowed for the Scissors away from a path. `None` for a cursor with a look of + /// its own. + pub fn stand_in(self) -> Option { + match self { + Cursor::Lasso + | Cursor::MagicWand + | Cursor::Pencil + | Cursor::Paintbrush + | Cursor::BlobBrush + | Cursor::Eraser + | Cursor::Smooth + | Cursor::PathEraser + | Cursor::Join + | Cursor::Knife + | Cursor::Shaper + | Cursor::LivePaintBucket + | Cursor::Measure + | Cursor::PrintTiling + | Cursor::SymbolSprayer + | Cursor::SymbolShifter + | Cursor::SymbolScruncher + | Cursor::SymbolSizer + | Cursor::SymbolSpinner + | Cursor::SymbolStainer + | Cursor::SymbolScreener + | Cursor::SymbolStyler + | Cursor::Scissors => Some(Cursor::Crosshair), + Cursor::TypeVertical => Some(Cursor::Text), + Cursor::MeshAdd | Cursor::PuppetPin => Some(Cursor::PenAdd), + Cursor::ScissorsNotAllowed => Some(Cursor::NotAllowed), + _ => None, + } + } } /// A tool state machine. @@ -749,3 +888,71 @@ pub(crate) mod testutil { } } } + +#[cfg(test)] +mod cursor_tests { + use super::testutil::{cx, doc_with_rect, paint}; + use super::*; + + fn cursor_at(id: &str, d: &Document, p: Point) -> Cursor { + let (s, paint) = (Selection::default(), paint()); + create(id).cursor(&cx(d, &s, &paint), p, Mods::default()) + } + + /// Each of these tools has a cursor of its own (borrowing the crosshair until it's drawn), and + /// Use Precise Cursors turns it into the crosshair. + #[test] + fn drawing_tools_show_their_own_cursor() { + let (d, _) = doc_with_rect(); + let away = Point::new(400.0, 400.0); + for (id, c) in [ + ("lasso", Cursor::Lasso), + ("magicWand", Cursor::MagicWand), + ("pencil", Cursor::Pencil), + ("paintbrush", Cursor::Paintbrush), + ("blobBrush", Cursor::BlobBrush), + ("eraser", Cursor::Eraser), + ("smooth", Cursor::Smooth), + ("pathEraser", Cursor::PathEraser), + ("join", Cursor::Join), + ("knife", Cursor::Knife), + ("shaper", Cursor::Shaper), + ("livePaintBucket", Cursor::LivePaintBucket), + ("measure", Cursor::Measure), + ("printTiling", Cursor::PrintTiling), + ("symbolSprayer", Cursor::SymbolSprayer), + ("symbolShifter", Cursor::SymbolShifter), + ("symbolScruncher", Cursor::SymbolScruncher), + ("symbolSizer", Cursor::SymbolSizer), + ("symbolSpinner", Cursor::SymbolSpinner), + ("symbolStainer", Cursor::SymbolStainer), + ("symbolScreener", Cursor::SymbolScreener), + ("symbolStyler", Cursor::SymbolStyler), + ("mesh", Cursor::MeshAdd), + ] { + assert_eq!(cursor_at(id, &d, away), c, "{id}"); + assert_eq!(c.precise(), Cursor::Crosshair, "{id}"); + assert_eq!(c.stand_in(), Some(if id == "mesh" { Cursor::PenAdd } else { Cursor::Crosshair }), "{id}"); + } + } + + #[test] + fn the_vertical_type_tools_show_the_turned_i_beam() { + let (d, _) = doc_with_rect(); + let away = Point::new(400.0, 400.0); + for id in ["verticalType", "verticalAreaType", "verticalTypeOnPath"] { + assert_eq!(cursor_at(id, &d, away), Cursor::TypeVertical, "{id}"); + } + assert_eq!(cursor_at("type", &d, away), Cursor::Text); + assert_eq!(Cursor::TypeVertical.stand_in(), Some(Cursor::Text)); + } + + #[test] + fn the_scissors_say_whether_a_click_cuts() { + let (d, _) = doc_with_rect(); + assert_eq!(cursor_at("scissors", &d, Point::new(100.0, 150.0)), Cursor::Scissors, "over a segment"); + assert_eq!(cursor_at("scissors", &d, Point::new(400.0, 400.0)), Cursor::ScissorsNotAllowed, "away from paths"); + assert_eq!(Cursor::ScissorsNotAllowed.stand_in(), Some(Cursor::NotAllowed)); + assert_eq!(Cursor::ScissorsNotAllowed.precise(), Cursor::ScissorsNotAllowed, "nothing to aim at"); + } +} diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index e5b747daf..8aa39cffd 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -241,7 +241,7 @@ impl Tool for MeshTool { match self.grab_at(cx, p) { Some(g) if mods.alt && g.handle.is_none() => Cursor::PenDelete, Some(_) => Cursor::Move, - None => Cursor::PenAdd, + None => Cursor::MeshAdd, } } fn options(&self) -> serde_json::Value { diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 5ad772365..ebc5be93e 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -152,6 +152,17 @@ fn alt_converts(cx: &ToolContext, p: Point, m: Mods) -> bool { || crate::draw2::segment_in(cx, crate::draw2::shown_paths(cx), p, cx.pick_tol()).is_some()) } +/// The cursor where [`alt_converts`]: Alt lends the Anchor Point tool, so its caret over a handle +/// end or an anchor, the Reshape Segment cursor over a segment. +fn alt_cursor(cx: &ToolContext, p: Point) -> Cursor { + let point = cx.point_tol(); + if hit_handle(cx, p, point).is_some() || crate::draw2::anchor_in(cx, crate::draw2::shown_paths(cx), p, point).is_some() { + Cursor::AnchorPoint + } else { + Cursor::ReshapeSegment + } +} + /// Preview the outgoing handle of anchor `ai` of subpath `si` at `h`, the incoming one left alone. fn set_out_handle(id: NodeId, si: usize, ai: usize, h: Point) -> Action { Action::Preview( @@ -390,6 +401,10 @@ impl Tool for PenTool { o } fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { + // An Alt gesture under way keeps the Anchor Point tool's cursor. + if let Some(t) = &self.convert { + return t.cursor(cx, p, m); + } let tol = cx.tol(5.0).max(cx.point_tol()); let active = self.active(cx); if let Some((id, first, last, _)) = active { @@ -404,7 +419,7 @@ impl Tool for PenTool { } } if alt_converts(cx, p, m) { - return Cursor::PenConvert; + return alt_cursor(cx, p); } // These show whenever no path is being drawn, also after another path was selected while // the Pen was drawing. @@ -1212,8 +1227,9 @@ mod tests { let alt = Mods { alt: true, ..Mods::default() }; let t = PenTool::default(); let cx0 = cx(&d, &s, &p); - assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), alt), Cursor::PenConvert); - assert_eq!(t.cursor(&cx0, Point::new(200.0, 101.0), alt), Cursor::PenConvert); + // Alt lends the Anchor Point tool, and its caret with it. + assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), alt), Cursor::AnchorPoint); + assert_eq!(t.cursor(&cx0, Point::new(200.0, 101.0), alt), Cursor::AnchorPoint); assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), Mods::default()), Cursor::Pen); assert_eq!(t.cursor(&cx0, Point::new(150.0, 300.0), alt), Cursor::Pen); // A drag of the handle moves it alone, as one gesture. @@ -1249,10 +1265,11 @@ mod tests { let cx = cx(&d, &s, &p); let alt = Mods { alt: true, ..Mods::default() }; let mut t = PenTool { drawing: true, ..PenTool::default() }; - assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), alt), Cursor::PenConvert); + assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), alt), Cursor::ReshapeSegment); assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), Mods::default()), Cursor::PenDrawing); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 302.0).with_mods(alt)), vec![]); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 262.0).with_mods(alt)); + assert_eq!(t.cursor(&cx, Point::new(150.0, 262.0), alt), Cursor::ReshapeSegment, "kept during the reshape"); assert_eq!(a[0], Action::Begin("Reshape".into())); assert!( matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["id"] == id.0 && v["segment"] == 0 && v["dy"] == -40.0), diff --git a/crates/tools/src/printtiling.rs b/crates/tools/src/printtiling.rs index 63cbd6c3c..ac1a1076a 100644 --- a/crates/tools/src/printtiling.rs +++ b/crates/tools/src/printtiling.rs @@ -87,7 +87,7 @@ impl Tool for PrintTilingTool { } fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Crosshair + Cursor::PrintTiling } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 2d9ad737a..b087256f8 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -82,6 +82,8 @@ pub struct SelectionTool { /// What a bounding-box handle being dragged snaps to. targets: Option, moving: Option, + /// The move snapped to a point or a guide at its last step: the arrow turns hollow. + snapped: bool, guide: GuideEdit, /// Smart Guides: the centre or anchor under the pointer, and its label ([`Self::hover_point`]). hover: Option<(Point, &'static str)>, @@ -425,9 +427,12 @@ impl Tool for SelectionTool { self.moving = Some(MoveSnap::new(cx)); } let mut d = move_delta(start, p, m.shift); + let unsnapped = d; if let Some(snap) = &self.moving { (d, self.guides) = snap.snap(cx, start, d, m.alt); } + // Shift has already constrained `d`: any change is a snap. + self.snapped = (d - unsnapped).hypot() > 1e-9; self.state = State::Moving { start, began: true, deselect: None, key: None }; self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y))); out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }))); @@ -553,7 +558,10 @@ impl Tool for SelectionTool { match self.state { State::Rotating { center, .. } => return Cursor::rotate_about(center, p), State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle), - State::Moving { began: true, .. } => return Cursor::Arrow, + // Alt-dragging leaves a copy behind; otherwise the filled arrow, hollow while the move + // snaps to a point or a guide. + State::Moving { began: true, .. } if m.alt => return Cursor::SelectionCopy, + State::Moving { began: true, .. } => return if self.snapped { Cursor::ArrowHollow } else { Cursor::Arrow }, State::Corner(_) => return Cursor::CornerRadius, State::Pie(_) => return Cursor::ArrowHollow, State::Bracket(_) => return Cursor::PathBracket, @@ -868,6 +876,33 @@ mod tests { assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0))), 103.0); } + /// A move shows the filled arrow, hollow while it snaps (here B's left side onto A's right + /// one); Alt, which leaves a copy behind, the double arrowhead. + #[test] + fn a_move_shows_the_filled_arrow_hollow_on_a_snap_and_alt_the_copy_cursor() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, _) = doc_with_rect(); + d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0); + let l = d.layers[0].id; + let b = d.alloc_id(); + let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 400.0, 400.0)); + d.insert(Some(l), 1, Node::path(b, sq, Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.add(b); + let p = paint(); + let c = cx(&d, &s, &p); + let none = Mods::default(); + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 350.0, 350.0)); + t.pointer(&c, &ev(PointerKind::Drag, 377.0, 371.0)); + assert_eq!(t.cursor(&c, Point::new(377.0, 371.0), none), Cursor::Arrow); + t.pointer(&c, &ev(PointerKind::Drag, 253.0, 371.0)); + assert_eq!(t.cursor(&c, Point::new(253.0, 371.0), none), Cursor::ArrowHollow, "lined up with A"); + let alt = Mods { alt: true, ..none }; + t.pointer(&c, &ev(PointerKind::Drag, 377.0, 371.0).with_mods(alt)); + assert_eq!(t.cursor(&c, Point::new(377.0, 371.0), alt), Cursor::SelectionCopy); + } + /// #740: with Snap to Grid on, a moved object lands on the grid (every 9 pt by default) by /// whichever of its edges or its centre is nearest a gridline, on each axis. #[test] diff --git a/crates/tools/src/symbolism.rs b/crates/tools/src/symbolism.rs index 62353070f..4d1757710 100644 --- a/crates/tools/src/symbolism.rs +++ b/crates/tools/src/symbolism.rs @@ -170,7 +170,16 @@ impl Tool for SymbolismTool { vec![Overlay::Path { path: circle, color: FEEDBACK, width: 1.0, dashed: false }] } fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Crosshair + match self.id { + "symbolShifter" => Cursor::SymbolShifter, + "symbolScruncher" => Cursor::SymbolScruncher, + "symbolSizer" => Cursor::SymbolSizer, + "symbolSpinner" => Cursor::SymbolSpinner, + "symbolStainer" => Cursor::SymbolStainer, + "symbolScreener" => Cursor::SymbolScreener, + "symbolStyler" => Cursor::SymbolStyler, + _ => Cursor::SymbolSprayer, + } } fn options(&self) -> Value { json!({"diameter": self.diameter, "intensity": self.intensity, "density": self.density, "symbol": self.symbol, "style": self.style}) diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 9d708b7d6..a48a4b366 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -814,7 +814,7 @@ impl Tool for TypeTool { if self.mode != Mode::Type && Self::path_at(cx, p, self.mode == Mode::Area).is_some() { return Cursor::Crosshair; } - Cursor::Text + if self.vertical { Cursor::TypeVertical } else { Cursor::Text } } } diff --git a/crates/tools/src/xform/free.rs b/crates/tools/src/xform/free.rs index 54099c817..6b218c900 100644 --- a/crates/tools/src/xform/free.rs +++ b/crates/tools/src/xform/free.rs @@ -315,7 +315,9 @@ impl Tool for FreeTransformTool { match self.drag.map(|d| d.op).or_else(|| self.classify(cx, r, p, m)) { Some(Op::Rotate) => Cursor::rotate_about(r.center(), p), Some(Op::Move) => Cursor::Move, - Some(Op::Scale(h) | Op::Shear(h)) => match h { + Some(Op::Shear(Handle::Top | Handle::Bottom)) => Cursor::ShearH, + Some(Op::Shear(_)) => Cursor::ShearV, + Some(Op::Scale(h)) => match h { Handle::Top | Handle::Bottom => Cursor::ResizeV, Handle::Left | Handle::Right => Cursor::ResizeH, Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe, @@ -401,6 +403,27 @@ mod tests { assert!((m * Point::new(100.0, 100.0)).distance(Point::new(120.0, 100.0)) < 1e-9); } + #[test] + fn cmd_over_a_side_shows_the_shear_cursor_along_it() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = FreeTransformTool::default(); + let cmd = Mods { cmd: true, ..Default::default() }; + let (top, left) = (Point::new(150.0, 100.0), Point::new(100.0, 150.0)); + assert_eq!(t.cursor(&cx, top, cmd), Cursor::ShearH); + assert_eq!(t.cursor(&cx, left, cmd), Cursor::ShearV); + // Without Cmd the sides scale; corners never shear. + assert_eq!(t.cursor(&cx, top, Mods::default()), Cursor::ResizeV); + assert_eq!(t.cursor(&cx, Point::new(200.0, 200.0), cmd), Cursor::Crosshair); + // The cursor stays while the shear is dragged. + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 100.0).with_mods(cmd)); + t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 100.0).with_mods(cmd)); + assert_eq!(t.cursor(&cx, Point::new(170.0, 100.0), cmd), Cursor::ShearH); + } + #[test] fn modifiers_pressed_during_a_handle_drag_change_what_it_does() { let (d, id) = doc_with_rect(); diff --git a/crates/tools/src/xform/measure.rs b/crates/tools/src/xform/measure.rs index c2160e668..b9f3ddcf2 100644 --- a/crates/tools/src/xform/measure.rs +++ b/crates/tools/src/xform/measure.rs @@ -68,7 +68,7 @@ impl Tool for MeasureTool { } fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Crosshair + Cursor::Measure } fn options(&self) -> Value { diff --git a/crates/tools/src/xform/wand.rs b/crates/tools/src/xform/wand.rs index 68f876e9b..be30d2638 100644 --- a/crates/tools/src/xform/wand.rs +++ b/crates/tools/src/xform/wand.rs @@ -41,7 +41,7 @@ impl Tool for MagicWandTool { } fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Crosshair + Cursor::MagicWand } } @@ -145,7 +145,7 @@ impl Tool for LassoTool { } fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Crosshair + Cursor::Lasso } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6d1b22c7c..edd0e31f8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -576,11 +576,20 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { fn cursor_icon(c: Cursor) -> egui::CursorIcon { use egui::CursorIcon as C; match c { - Cursor::Arrow | Cursor::ArrowHollow | Cursor::CornerRadius | Cursor::PathBracket | Cursor::TypeWidget => C::Default, + Cursor::Arrow + | Cursor::ArrowHollow + | Cursor::DirectAnchor + | Cursor::DirectAnchorSelected + | Cursor::ReshapeSegment + | Cursor::GroupSelection + | Cursor::CornerRadius + | Cursor::PathBracket + | Cursor::TypeWidget => C::Default, + Cursor::SelectionCopy => C::Copy, Cursor::Move => C::Move, Cursor::Crosshair => C::Crosshair, - Cursor::ResizeH => C::ResizeHorizontal, - Cursor::ResizeV => C::ResizeVertical, + Cursor::ResizeH | Cursor::ShearH => C::ResizeHorizontal, + Cursor::ResizeV | Cursor::ShearV => C::ResizeVertical, Cursor::ResizeNwSe => C::ResizeNwSe, Cursor::ResizeNeSw => C::ResizeNeSw, Cursor::Rotate | Cursor::RotateToward(_) => C::Alias, @@ -591,7 +600,9 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { | Cursor::PenClose | Cursor::PenContinue | Cursor::PenJoin - | Cursor::PenConvert => C::Crosshair, + | Cursor::PenConvert + | Cursor::AnchorPoint + | Cursor::Curvature => C::Crosshair, Cursor::Text => C::Text, Cursor::Hand => C::Grab, Cursor::HandGrab => C::Grabbing, @@ -606,6 +617,34 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::Width | Cursor::WidthAdd => C::Crosshair, Cursor::WidthPoint => C::Move, Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor | Cursor::ShapeBuilder | Cursor::ShapeBuilderErase => C::Crosshair, + Cursor::TypeVertical => C::VerticalText, + // Not drawn yet: the cursor each borrows. + Cursor::Lasso + | Cursor::MagicWand + | Cursor::Pencil + | Cursor::Paintbrush + | Cursor::BlobBrush + | Cursor::Eraser + | Cursor::Smooth + | Cursor::PathEraser + | Cursor::Join + | Cursor::Knife + | Cursor::Shaper + | Cursor::LivePaintBucket + | Cursor::Measure + | Cursor::PrintTiling + | Cursor::SymbolSprayer + | Cursor::SymbolShifter + | Cursor::SymbolScruncher + | Cursor::SymbolSizer + | Cursor::SymbolSpinner + | Cursor::SymbolStainer + | Cursor::SymbolScreener + | Cursor::SymbolStyler + | Cursor::Scissors + | Cursor::ScissorsNotAllowed + | Cursor::MeshAdd + | Cursor::PuppetPin => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)), } } diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index febf7083c..bdf2d127c 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -95,6 +95,13 @@ fn pen(p: &mut Ink, o: Pos2, badge: &str) { } "-" => line(p, b + vec2(0.0, 3.0), b + vec2(6.0, 3.0)), "/" => line(p, b + vec2(0.0, 6.0), b + vec2(5.0, 0.0)), + "~" => { + // A quarter circle bulging up-left, from the badge's bottom-left to its top-right. + let c = b + vec2(7.0, 7.0); + let arc: Vec = (0..=8).map(|i| c + egui::Vec2::angled(std::f32::consts::PI * (1.0 + i as f32 / 16.0)) * 6.0).collect(); + p.add(Shape::line(arc.clone(), Stroke::new(3.0, HALO))); + p.add(Shape::line(arc, Stroke::new(1.2, INK))); + } // Join: a segment running into a square end point. "j" => { line(p, b + vec2(0.0, 6.0), b + vec2(3.5, 2.5)); @@ -114,6 +121,79 @@ fn pen(p: &mut Ink, o: Pos2, badge: &str) { } } +/// The Anchor Point tool's caret (as its toolbar icon): a small hollow square on the anchor at +/// the hotspot, legs spreading down from its lower corners. Every halo goes under every stroke, so +/// the legs join the square. +fn anchor_point(p: &mut Ink, o: Pos2) { + let s = 2.5; + let square = vec![o + vec2(-s, -s), o + vec2(s, -s), o + vec2(s, s), o + vec2(-s, s)]; + let legs = [[o + vec2(-s, s), o + vec2(-7.5, 13.0)], [o + vec2(s, s), o + vec2(7.5, 13.0)]]; + for leg in legs { + p.line_segment(leg, Stroke::new(3.0, HALO)); + } + p.add(Shape::closed_line(square.clone(), Stroke::new(3.0, HALO))); + p.add(Shape::convex_polygon(square.clone(), HALO, Stroke::NONE)); + for leg in legs { + p.line_segment(leg, Stroke::new(1.2, INK)); + } + p.add(Shape::closed_line(square, Stroke::new(1.0, INK))); +} + +/// Halos under every stroke first, then the ink, so crossing strokes don't cut each other. +fn strokes(p: &mut Ink, parts: &[Vec]) { + for s in parts { + p.add(Shape::line(s.clone(), Stroke::new(3.0, HALO))); + } + for s in parts { + p.add(Shape::line(s.clone(), Stroke::new(1.2, INK))); + } +} + +/// A stemless arrowhead, tip at `o`. +fn arrowhead(o: Pos2) -> Vec { + vec![o, o + vec2(0.0, 14.0), o + vec2(4.2, 10.4), o + vec2(10.0, 10.0)] +} + +/// Direct Selection over an anchor: the white arrow with a small square at its lower right, +/// filled when the anchor's path is selected. +fn arrow_square(p: &mut Ink, o: Pos2, filled: bool) { + arrow(p, o, true); + let b = o + vec2(11.0, 13.0); + let square = [(0.0, 0.0), (5.0, 0.0), (5.0, 5.0), (0.0, 5.0)].map(|(x, y)| b + vec2(x, y)).to_vec(); + poly(p, square, if filled { INK } else { HALO }, INK); +} + +/// The Reshape Segment cursor: a black arrowhead with a small curve between two anchor marks at +/// its lower right. +fn reshape_segment(p: &mut Ink, o: Pos2) { + poly(p, arrowhead(o), INK, INK); + let (a, c) = (o + vec2(10.0, 19.0), o + vec2(19.0, 19.0)); + let curve: Vec = (0..=12) + .map(|i| { + let t = i as f32 / 12.0; + a + (c - a) * t - vec2(0.0, 4.5 * (std::f32::consts::PI * t).sin()) + }) + .collect(); + strokes(p, &[curve]); + for m in [a, c] { + let mark = [(-1.5, -1.5), (1.5, -1.5), (1.5, 1.5), (-1.5, 1.5)].map(|(x, y)| m + vec2(x, y)).to_vec(); + poly(p, mark, INK, INK); + } +} + +/// Group Selection: the white arrow with a plus at its lower right. +fn arrow_plus(p: &mut Ink, o: Pos2) { + arrow(p, o, true); + let c = o + vec2(14.0, 15.0); + strokes(p, &[vec![c - vec2(3.5, 0.0), c + vec2(3.5, 0.0)], vec![c - vec2(0.0, 3.5), c + vec2(0.0, 3.5)]]); +} + +/// An Alt-drag copy: a black arrowhead in front of a white one set down and to the right. +fn double_arrowhead(p: &mut Ink, o: Pos2) { + poly(p, arrowhead(o + vec2(4.0, 4.0)), HALO, INK); + poly(p, arrowhead(o), INK, INK); +} + fn double_arrow(p: &mut Ink, o: Pos2, dir: egui::Vec2) { let d = dir.normalized() * 8.0; let n = vec2(-d.y, d.x) * 0.45; @@ -123,6 +203,19 @@ fn double_arrow(p: &mut Ink, o: Pos2, dir: egui::Vec2) { } } +/// Two single arrows side by side across the hotspot, pointing opposite ways along `dir`: a side +/// sliding past its opposite one, as a shear does. +fn shear(p: &mut Ink, o: Pos2, dir: egui::Vec2) { + let d = dir.normalized() * 7.0; + let n = vec2(-d.y, d.x) / 7.0; + for (side, way) in [(-3.5, 1.0), (3.5, -1.0)] { + let (c, d) = (o + n * side, d * way); + line(p, c - d, c + d * 0.55); + let back = c + d * 0.4; + poly(p, vec![c + d, back + n * 2.6, back - n * 2.6], INK, INK); + } +} + /// A curved double arrow over the hotspot: an arc with an arrowhead at each end, pointing on along /// the arc (#701). The arc bows up, or toward `octant` (0 up, then clockwise in 45° steps): beside a /// bounding box it wraps the corner the pointer is at. @@ -314,13 +407,102 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) { } } +/// Every cursor with a glyph: its own, drawn here, or the one it borrows. +#[cfg(test)] +const GLYPHS: [Cursor; 74] = [ + Cursor::Hand, + Cursor::HandGrab, + Cursor::ZoomIn, + Cursor::ZoomOut, + Cursor::Arrow, + Cursor::ArrowHollow, + Cursor::DirectAnchor, + Cursor::DirectAnchorSelected, + Cursor::ReshapeSegment, + Cursor::GroupSelection, + Cursor::SelectionCopy, + Cursor::Move, + Cursor::Crosshair, + Cursor::ResizeH, + Cursor::ResizeV, + Cursor::ResizeNwSe, + Cursor::ResizeNeSw, + Cursor::ShearH, + Cursor::ShearV, + Cursor::Rotate, + Cursor::RotateToward(1), + Cursor::CornerRadius, + Cursor::Pen, + Cursor::PenDrawing, + Cursor::PenAdd, + Cursor::PenDelete, + Cursor::PenClose, + Cursor::PenContinue, + Cursor::PenJoin, + Cursor::PenConvert, + Cursor::AnchorPoint, + Cursor::Curvature, + Cursor::Text, + Cursor::Eyedropper, + Cursor::AddStop, + Cursor::RemoveStop, + Cursor::Slice, + Cursor::SliceSelect, + Cursor::Width, + Cursor::WidthAdd, + Cursor::WidthPoint, + Cursor::Blend, + Cursor::BlendObject, + Cursor::BlendAnchor, + Cursor::PathBracket, + Cursor::TypeWidget, + Cursor::ShapeBuilder, + Cursor::ShapeBuilderErase, + Cursor::Lasso, + Cursor::MagicWand, + Cursor::Pencil, + Cursor::Paintbrush, + Cursor::BlobBrush, + Cursor::Eraser, + Cursor::Smooth, + Cursor::PathEraser, + Cursor::Join, + Cursor::Knife, + Cursor::Shaper, + Cursor::LivePaintBucket, + Cursor::Measure, + Cursor::PrintTiling, + Cursor::SymbolSprayer, + Cursor::SymbolShifter, + Cursor::SymbolScruncher, + Cursor::SymbolSizer, + Cursor::SymbolSpinner, + Cursor::SymbolStainer, + Cursor::SymbolScreener, + Cursor::SymbolStyler, + Cursor::TypeVertical, + Cursor::Scissors, + Cursor::MeshAdd, + Cursor::PuppetPin, +]; + /// The shapes of cursor `c` with its hotspot at `p`; `None` for cursors that stay system cursors -/// (the busy states). +/// (the busy states): the one drawn here, else the glyph of the cursor it borrows +/// ([`Cursor::stand_in`]). fn glyph(c: Cursor, p: Pos2) -> Option> { let ink = &mut Ink::default(); + // A cursor not drawn yet: the one it borrows. + if let Some(stand_in) = c.stand_in() { + return glyph(stand_in, p); + } match c { Cursor::Arrow => arrow(ink, p, false), Cursor::ArrowHollow => arrow(ink, p, true), + Cursor::DirectAnchor => arrow_square(ink, p, false), + Cursor::DirectAnchorSelected => arrow_square(ink, p, true), + Cursor::ReshapeSegment => reshape_segment(ink, p), + Cursor::GroupSelection => arrow_plus(ink, p), + Cursor::SelectionCopy => double_arrowhead(ink, p), Cursor::Move => { arrow(ink, p, false); double_arrow(ink, p + vec2(15.0, 18.0), vec2(1.0, 0.0)); @@ -331,6 +513,8 @@ fn glyph(c: Cursor, p: Pos2) -> Option> { Cursor::ResizeV => double_arrow(ink, p, vec2(0.0, 1.0)), Cursor::ResizeNwSe => double_arrow(ink, p, vec2(1.0, 1.0)), Cursor::ResizeNeSw => double_arrow(ink, p, vec2(1.0, -1.0)), + Cursor::ShearH => shear(ink, p, vec2(1.0, 0.0)), + Cursor::ShearV => shear(ink, p, vec2(0.0, -1.0)), Cursor::Rotate => rotate(ink, p, 0), Cursor::RotateToward(octant) => rotate(ink, p, octant), Cursor::CornerRadius => corner_radius(ink, p), @@ -340,6 +524,8 @@ fn glyph(c: Cursor, p: Pos2) -> Option> { Cursor::PenDelete => pen(ink, p, "-"), Cursor::PenClose => pen(ink, p, "o"), Cursor::PenContinue => pen(ink, p, "/"), + Cursor::AnchorPoint => anchor_point(ink, p), + Cursor::Curvature => pen(ink, p, "~"), Cursor::PenJoin => pen(ink, p, "j"), Cursor::PenConvert => pen(ink, p, "^"), Cursor::Text => ibeam(ink, p), @@ -519,55 +705,68 @@ mod tests { use crate::VectorcraftApp; - /// Every cursor with a glyph. - const GLYPHS: [Cursor; 39] = [ - Cursor::Hand, - Cursor::HandGrab, - Cursor::ZoomIn, - Cursor::ZoomOut, - Cursor::Arrow, - Cursor::ArrowHollow, - Cursor::Move, - Cursor::Crosshair, - Cursor::ResizeH, - Cursor::ResizeV, - Cursor::ResizeNwSe, - Cursor::ResizeNeSw, - Cursor::Rotate, - Cursor::RotateToward(1), - Cursor::CornerRadius, - Cursor::Pen, - Cursor::PenDrawing, - Cursor::PenAdd, - Cursor::PenDelete, - Cursor::PenClose, - Cursor::PenContinue, - Cursor::PenJoin, - Cursor::PenConvert, - Cursor::Text, - Cursor::Eyedropper, - Cursor::AddStop, - Cursor::RemoveStop, - Cursor::Slice, - Cursor::SliceSelect, - Cursor::Width, - Cursor::WidthAdd, - Cursor::WidthPoint, - Cursor::Blend, - Cursor::BlendObject, - Cursor::BlendAnchor, - Cursor::PathBracket, - Cursor::TypeWidget, - Cursor::ShapeBuilder, - Cursor::ShapeBuilderErase, - ]; - /// The straight RGBA of pixel (x, y). fn pixel(img: &CustomCursorImage, x: usize, y: usize) -> [u8; 4] { let i = (y * img.size[0] as usize + x) * 4; [img.rgba[i], img.rgba[i + 1], img.rgba[i + 2], img.rgba[i + 3]] } + /// The Anchor Point caret's hotspot is the middle of its hollow square: white there, with the + /// square's ink a few points above it. + #[test] + fn anchor_point_hotspot_is_inside_its_square() { + for ppp in [1.0f32, 2.0] { + let img = rasterize(Cursor::AnchorPoint, ppp).expect("anchor point has a bitmap"); + let [hx, hy] = img.hotspot.map(usize::from); + assert_eq!(pixel(&img, hx, hy), [255, 255, 255, 255], "hollow at the hotspot at {ppp}x"); + let reach = (4.0 * ppp).round() as usize; + let inked = (1..=reach.min(hy)).any(|d| { + let p = pixel(&img, hx, hy - d); + p[0] < 64 && p[3] > 200 + }); + assert!(inked, "ink on the square's top edge within 4 points above the hotspot at {ppp}x"); + } + } + + /// Direct Selection's anchor square is hollow over an unselected path and filled over a + /// selected one; the arrow stays white. The Reshape Segment and copy arrowheads are black and + /// Group Selection's arrow white, just inside their tips. + #[test] + fn selection_arrows_are_white_or_black() { + let at = |img: &CustomCursorImage, x: f32, y: f32, ppp: f32| { + let [hx, hy] = img.hotspot.map(usize::from); + pixel(img, hx + (x * ppp).round() as usize, hy + (y * ppp).round() as usize) + }; + for ppp in [1.0f32, 2.0] { + for c in [Cursor::DirectAnchor, Cursor::DirectAnchorSelected] { + let img = rasterize(c, ppp).expect("a bitmap"); + let tip = at(&img, 1.5, 6.0, ppp); + assert!(tip[0] > 192 && tip[3] > 200, "{c:?}'s arrow is white at {ppp}x: {tip:?}"); + } + // Same arrow, so the filled square shows as more ink than the hollow one. + let ink = |c| { + let img = rasterize(c, ppp).expect("a bitmap"); + img.rgba.as_chunks::<4>().0.iter().filter(|p| p[0] < 64 && p[3] > 200).count() + }; + let (hollow, filled) = (ink(Cursor::DirectAnchor), ink(Cursor::DirectAnchorSelected)); + assert!(filled > hollow, "the selected path's square is filled at {ppp}x: {filled} vs {hollow}"); + for (c, white) in [(Cursor::ReshapeSegment, false), (Cursor::SelectionCopy, false), (Cursor::GroupSelection, true)] { + let img = rasterize(c, ppp).expect("a bitmap"); + let tip = at(&img, 1.5, 6.0, ppp); + assert_eq!(tip[0] > 192, white, "{c:?} at {ppp}x: {tip:?}"); + assert!(tip[3] > 200, "{c:?} at {ppp}x: {tip:?}"); + } + } + } + + #[test] + fn a_cursor_not_drawn_yet_looks_like_the_one_it_borrows() { + for c in GLYPHS { + let Some(stand_in) = c.stand_in() else { continue }; + assert_eq!(rasterize(c, 2.0), rasterize(stand_in, 2.0), "{c:?} borrows {stand_in:?}"); + } + } + #[test] fn every_glyph_becomes_a_bitmap_with_its_hotspot_inside() { for ppp in [1.0, 1.25, 1.5, 2.0, 3.0] { @@ -590,6 +789,7 @@ mod tests { assert!(pixel(&dropper, hx, hy)[3] > 0, "the tip covers the hotspot at {ppp}"); } assert!(rasterize(Cursor::NotAllowed, 1.0).is_none(), "the busy states stay system cursors"); + assert!(rasterize(Cursor::ScissorsNotAllowed, 1.0).is_none(), "until it's drawn it borrows the system not-allowed cursor"); for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY, 1e9] { assert!(rasterize(Cursor::Arrow, ppp).is_none(), "{ppp}"); } diff --git a/docs/development.md b/docs/development.md index b7c14b39f..978fa2e4a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -244,6 +244,10 @@ other `sv-*` locale resolve to it), Czech (`cs`, every menu label) and Brazilian system font; the glyph test checks Latin and Cyrillic catalogs always and the CJK ones only with craft-fonts. A Traditional Chinese UI font is still to be added to craft-fonts for the web build. +## Cursors + +`crates/tools/src/cursors.toml` lists every `Cursor` value: when it shows, in plain behaviour terms, and where its hotspot is, so whoever draws the cursors knows what each one is for without reading the tools (CLAUDE.md › Adding a cursor). `cargo xtask cursors` (part of `cargo xtask ci`) warns about a `Cursor` value with no entry, an entry with no `Cursor` value, a repeated id or a missing field; it never fails the build. A cursor not drawn yet borrows another's look (`Cursor::stand_in`). + ## Vendor names gate `cargo xtask brands` (part of `cargo xtask ci`) fails when user-visible text names another vendor's products or company: string literals in Rust sources (command labels and params docs, menus, panels, MCP tool definitions), `Cargo.toml` descriptions and packaging files. Comments and test code are not checked. Say "the reference app" or name the feature itself. A line that must keep an old name, such as an alias that files or preferences from earlier versions still use, carries a `brand-ok` comment. diff --git a/docs/gaps.md b/docs/gaps.md index fa436d9f9..9d3e6e248 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -70,6 +70,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall OS cursor display and Caps Lock remain unverified. - **Done (#1251):** Eyedropper source reads are independent of locks and isolation, including Shift/Shift+Alt and gradient stops. Source and selection preservation, unchanged Alt reverse/selection restrictions, hidden art and Undo/Redo have regression coverage. This addresses the reported behavior, with no new full-parity measurement. - **Done (#1253):** adding paint to an unpainted zero-width stroke restores the existing 1 pt default, including new-art defaults and Swap Fill/Stroke. The real Eyedropper inheritance sequence, targeted appearance items, preserved widths and Undo/Redo have regression coverage. +- **Missing:** a busy cursor while a long operation runs (a big file opening, a heavy effect): VectorCraft has no busy state to show it yet. Once it has one, its `Cursor` value goes in the cursor list (`crates/tools/src/cursors.toml`). - **Missing:** Average and Join in one step, with a corner or smooth choice for the joined anchor. `path.average` and `path.join` are separate commands and Join has no such choice (coinciding anchors merge into one since #1533). [ui-parity.md](ui-parity.md) listed "Join with corner/smooth choice" as done until 2026-10-13. About 1–2 h (estimated), inside the estimate below. - **Implemented (#1760):** Effect › Warp has a shared style selector with original previews, orientation buttons and percent sliders. Existing effects retain their stack slot and target. Focused checks passed and original captures were reviewed; optional Properties integration and remembered defaults remain outside this change. - **Done (#2120):** Smart Guides line extension: dragging the end of a straight segment with Direct Selection or Curvature, the point snaps to the segment's own direction at any angle and the guide runs along it from the other end. Inside the estimate below. @@ -333,6 +334,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). | | 2026-10-16 | minor | G1: Smart Guides line extension for a dragged end of a straight segment, at any angle (#2120); estimate unchanged | +| 2026-10-15 | minor | G1: no busy cursor during long operations, listed; estimate unchanged | | 2026-10-14 | minor | G16: Brazilian Portuguese (`pt-br`) catalog complete under the tests (#1914); three key languages remain (Hindi, Arabic, Indonesian), 55–90 → 50–85 h | | 2026-10-14 | minor | G6: dataset CSV import/export (`dataset.import` / `dataset.export`); remaining: scripting surface, Batch, graph variables; estimate 6–12 → 4–8 h | | 2026-10-14 | minor | G6: image variables (`VariableKind::Image`, `variable.makeImageDynamic`, path values on `dataset.select` keeping bounds); remaining: scripting surface, Batch, graph variables, dataset import/export; estimate 8–14 → 6–12 h | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0effc81cf..6c95df813 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -645,7 +645,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns. The Layers panel's Delete Selection, Duplicate Selection, Collect in New Layer, Merge Selected, Flatten Artwork, Release to Layers (Sequence) and row drags also take time in proportion to the size of the document, and so do Cut Artboard and Duplicate Artboard with their art and each move of an Alt-drag of one artboard with the Artboard tool while Move/Copy Artwork with Artboard is on (#1429). In Outline view the renderer draws paths and compound paths from the geometry it keeps for them between frames, and it culls the members of clip groups by the bounds it keeps for them. Layers with Preview off and the outlines of Image Trace objects are drawn the same way. -- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; after a start that a graphics driver crashed before its first frame, the next starts leave that adapter out, or list OpenGL's adapters alone when the crash came while the adapters were being listed (#2243; verified by unit tests, not on the reporter's Intel UHD Graphics 605); Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland. +- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; after a start that a graphics driver crashed before its first frame, the next starts leave that adapter out, or list OpenGL's adapters alone when the crash came while the adapters were being listed (#2243; verified by unit tests, not on the reporter's Intel UHD Graphics 605); Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Tool states have cursors of their own as the public manual describes them: Direct Selection shows a hollow or filled square on its white arrow over an anchor (path unselected or selected) and the Reshape Segment cursor over a curved segment, as do the Anchor Point tool and the Pen with Alt (whose caret it lends); Group Selection, and Direct Selection with Alt, show a plus; a drag shows the filled arrow, hollow while it snaps to a point or a guide, and an Alt-drag copy a double arrowhead; Curvature has its own pen and Free Transform's Cmd/Ctrl side-shear two arrows passing along the side. Lasso, Magic Wand, the freehand tools, Shaper, Live Paint Bucket, Measure, Print Tiling, the symbolism tools, vertical type, Scissors, Mesh and Puppet Warp return cursors of their own, which borrow the crosshair, the I-beam, the Pen's add cursor or not-allowed until they're drawn; there's no busy cursor yet. `crates/tools/src/cursors.toml` lists every cursor, when it shows and its hotspot, for whoever draws them. Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland. - **Control transport:** idle sockets expire after 5 minutes, partial lines after an absolute 30 seconds from their first byte, and reply writes after 60 seconds (#1726). Real TCP regressions cover stalled peers and slot recovery; command execution has its separate existing timeout. MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727). MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). The MCP server reconnects to the app before a call when the connection was closed or holds stray bytes, after an app restart or an idle expiry (#1739). - **MCP vertical type:** `add_text {vertical: true}` forwards to `text.create` or `text.createInPath`. Regression coverage checks point type, rectangular area type, type in a path and type on a path with true, false and omitted direction. This exposes an existing engine option; it does not change glyph orientation, CJK composition or the type/automation scores. - **Malformed MCP replies:** invalid JSON from the app drops the connection, reports that the operation may have completed and leaves the next call to reconnect; it never replays the edit (#1761). TCP regressions cover malformed and uncorrelated replies, connection closure and a subsequent inspection. Envelope regressions also verify zero backend calls and unchanged initialization state for invalid versions. @@ -687,6 +687,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | 2026-10-16 | minor | Align Stroke on live type (beyond Illustrator): the characters' stroke goes inside or outside the glyphs without creating outlines (#2294); scores and hours unchanged | | 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). | | 2026-10-16 | minor | Symbols: Symbol Options › Export Type (Graphic / Movie Clip) with Registration; Symbols score 85% → 87% (estimated) | +| 2026-10-15 | minor | Evidence: 36 more tool states get cursors of their own (node editing, Anchor Point, Curvature, side-shear drawn; the rest borrowed until drawn), listed in `crates/tools/src/cursors.toml`; scores unchanged | | 2026-10-14 | minor | Localization: Brazilian Portuguese (`pt-br`) catalog completed under the tests (#1914); Localization 71% → 74% (measured), hours 55–90 → 50–85; full readiness unchanged (~62%) | | 2026-10-14 | minor | Automation: dataset CSV import/export; headline Automation ~62% → ~65%, detail ~68% → ~72%, hours 6–12 → 4–8 / 5–9 → 4–7 (estimated) | | 2026-10-14 | minor | Automation: image variables (`kind: image`, `variable.makeImageDynamic`, path values on datasets that keep object bounds); headline Automation ~60% → ~62%, detail ~65% → ~68%, hours 8–14 → 6–12 / 6–10 → 5–9 (estimated) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index de4d0a3cd..dfbd47ff0 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -51,6 +51,11 @@ tools, never checked side by side) isn't captured row by row. | Rubber Band preview | done | Preferences option | | Auto Add/Delete on a selected path; Shift or the preference disables it | done | | | Pen cursors (new path ×, continue /, close o, add +, delete −, convert ^) | partial | vector cursors exist; not every state verified | +| Node-editing cursors: Direct Selection's white arrow with a hollow or filled square over an anchor (path unselected or selected); the Reshape Segment cursor over a curved segment (Direct Selection, Anchor Point, the Pen with Alt); the Anchor Point caret for the Pen with Alt; Group Selection's plus (also Direct Selection with Alt); a filled arrow while dragging that turns hollow on a snap; a double arrowhead for an Alt-drag copy | partial | as the public manual describes them, drawn in code; no cue over handles (none documented) | +| Anchor Point and Curvature tools' own cursors | done | the caret on the anchor it converts; the pen nib with a curve badge | +| Free Transform's Cmd/Ctrl side-shear cursor | done | two arrows passing along the side (ShearH, ShearV), instead of the resize arrow across it | +| Cursors of their own for Lasso, Magic Wand, the freehand tools, Shaper, Live Paint Bucket, Measure, Print Tiling, the symbolism tools, vertical type, Scissors (over a segment and away), Mesh and Puppet Warp | partial | each tool returns its own cursor and Use Precise Cursors turns them into the crosshair; until they're drawn they borrow the crosshair, the I-beam, the Pen's add cursor or not-allowed | +| Busy cursor during long operations | missing | VectorCraft has no busy state yet (gap G1) | | Each anchor snaps to Smart Guides and construction angles | done | `DrawSnap` | | 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 | | Add/Delete/Anchor Point tools; Anchor Point tool drags segments to reshape | done | #2267: a click on a handle end retracts that handle and leaves the anchor a corner with the other one | @@ -232,6 +237,7 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, # | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-16 | minor | Anchor Point tool: a click on one handle end retracts that handle only; the anchor becomes a corner and keeps the other handle (#2267). Handle drags start past the same 2 px as anchor drags. Scores and hours unchanged. | | 2026-10-16 | minor | Smart Guides: the dragged end of a straight segment snaps to the segment's extension at any angle, with the guide from its other end (#2120); scores and hours unchanged | +| 2026-10-15 | minor | Cursors of their own for 36 more tool states: node-editing cursors as the public manual describes them, Anchor Point and Curvature, Free Transform's side-shear; the rest borrow a drawn cursor until they're drawn. Busy cursor listed as missing. Every cursor is listed in `crates/tools/src/cursors.toml` | | 2026-10-14 | minor | Effect > Warp: style, orientation and percent controls; focused checks passed and original captures reviewed; scores and hours unchanged | | 2026-10-14 | minor | G11: open-path anchor Delete leaves separate surviving runs; dedicated Remove Anchors remains connected. Keyboard and history regression coverage added; scores and hours unchanged. | | 2026-10-14 | minor | Curvature: the Curvature tool's curve: 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°), and the ends of an open path curve in, so uneven spacing neither overshoots nor loops; the rubber band, dragged, inserted and converted points use the same rule; the Pencil's fitting unchanged (#1236, in part); scores unchanged (estimated) | diff --git a/xtask/src/brands.rs b/xtask/src/brands.rs index 1bcb38898..9e722aa9e 100644 --- a/xtask/src/brands.rs +++ b/xtask/src/brands.rs @@ -6,6 +6,7 @@ //! MCP tool definitions…). Comments and test code (`tests*.rs`, `*_tests.rs`, `tests/` and //! `benches/` folders, items under `#[cfg(test)]`) are skipped; //! * the `description` of every `Cargo.toml`; +//! * the cursor manifest `crates/tools/src/cursors.toml` (designers read it); //! * every text file under `packaging/`, and under `docs/` except Markdown pages: Markdown docs //! (like `README.md` and `ROADMAP.md`) may name the reference app and compare against it. //! @@ -38,6 +39,9 @@ const ALLOW: &str = "brand-ok"; /// This file lists the names it rejects. const SELF: &str = "xtask/src/brands.rs"; +/// The cursor manifest, which designers read. +const CURSOR_MANIFEST: &str = "crates/tools/src/cursors.toml"; + /// A denied name found in a file. #[derive(Debug, PartialEq)] pub struct Hit { @@ -89,7 +93,7 @@ enum Kind { } fn kind_of(path: &str) -> Option { - if (path.starts_with("docs/") && !path.ends_with(".md")) || path.starts_with("packaging/") { + if (path.starts_with("docs/") && !path.ends_with(".md")) || path.starts_with("packaging/") || path == CURSOR_MANIFEST { Some(Kind::Text) } else if path == "Cargo.toml" || path.ends_with("/Cargo.toml") { Some(Kind::Manifest) @@ -351,6 +355,7 @@ line Pantone"; } assert_eq!(kind_of("crates/ui-egui/Cargo.toml"), Some(Kind::Manifest)); assert_eq!(kind_of("docs/brand/LICENSE-brand.txt"), Some(Kind::Text)); assert_eq!(kind_of("packaging/linux/nfpm.yaml"), Some(Kind::Text)); + assert_eq!(kind_of(CURSOR_MANIFEST), Some(Kind::Text)); for skipped in ["README.md", "ROADMAP.md", "CLAUDE.md", "AGENTS.md", ".github/workflows/release.yml", "ASSETS.md", "docs/mcp.md"] { assert_eq!(kind_of(skipped), None, "{skipped}"); } diff --git a/xtask/src/cursors.rs b/xtask/src/cursors.rs new file mode 100644 index 000000000..71ef6c54a --- /dev/null +++ b/xtask/src/cursors.rs @@ -0,0 +1,145 @@ +//! `cargo xtask cursors`: does the cursor manifest (`crates/tools/src/cursors.toml`) cover every +//! `Cursor` value? +//! +//! The manifest check only warns: a cursor without an entry still works in the app (it falls +//! back), it just isn't listed for whoever draws the cursors. Development never stops for a missing cursor. + +use std::collections::BTreeSet; +use std::path::Path; + +pub const ENUM_FILE: &str = "crates/tools/src/lib.rs"; +pub const MANIFEST: &str = "crates/tools/src/cursors.toml"; + +pub fn run(root: &Path) -> Result<(), String> { + let read = |p: &str| std::fs::read_to_string(root.join(p)).map_err(|e| format!("{p}: {e}")); + let (enum_src, manifest) = (read(ENUM_FILE)?, read(MANIFEST)?); + let warnings = check(&enum_src, &manifest); + if warnings.is_empty() { + println!("cursors: every Cursor has an entry in {MANIFEST}"); + } + for w in &warnings { + eprintln!("warning: {w}"); + } + Ok(()) +} + +/// Warnings for `Cursor` variants in `enum_src` and `[[cursor]]` entries in `manifest` that don't +/// match, and for entries that repeat an id or lack a required field. +pub fn check(enum_src: &str, manifest: &str) -> Vec { + let variants = variants(enum_src); + let entries = entries(manifest); + let mut out = vec![]; + if variants.is_empty() { + out.push(format!("no `pub enum Cursor` found in {ENUM_FILE}")); + } + let mut ids = BTreeSet::new(); + for e in &entries { + let name = e.id.as_deref().unwrap_or("?"); + match &e.id { + None => out.push(format!("{MANIFEST}:{}: a [[cursor]] without an id", e.line)), + Some(id) if !ids.insert(id.as_str()) => out.push(format!("{MANIFEST}:{}: {id} is listed twice", e.line)), + Some(id) if !variants.iter().any(|v| v.name == *id) => out.push(format!("{MANIFEST}:{}: {id} is not a Cursor value", e.line)), + Some(_) => {} + } + for field in ["shown_when", "hotspot"] { + if !e.fields.contains(field) { + out.push(format!("{MANIFEST}:{}: {name} has no {field}", e.line)); + } + } + } + for v in variants.iter().filter(|v| !ids.contains(v.name.as_str())) { + out.push(format!("Cursor::{} has no entry in {MANIFEST} (see CLAUDE.md › Adding a cursor)", v.name)); + } + out +} + +/// A `Cursor` variant, by name (`RotateToward(u8)` is `RotateToward`). +pub struct Variant { + pub name: String, +} + +/// The variants of `pub enum Cursor` in `src`, in order. +pub fn variants(src: &str) -> Vec { + src.lines() + .skip_while(|l| !l.starts_with("pub enum Cursor {")) + .skip(1) + .take_while(|l| !l.starts_with('}')) + .map(str::trim) + .filter(|l| l.starts_with(|c: char| c.is_ascii_uppercase())) + .map(|l| Variant { name: l.chars().take_while(|c| c.is_ascii_alphanumeric()).collect() }) + .collect() +} + +/// One `[[cursor]]` table: its first line (1-based), its `id`, the keys it sets, and whether +/// designers draw it (`designer = false`: the operating system does). +pub struct Entry { + line: usize, + pub id: Option, + fields: BTreeSet, + pub designer: bool, +} + +/// The `[[cursor]]` tables of `manifest`, read line by line (keys at the start of a line). +pub fn entries(manifest: &str) -> Vec { + let mut out: Vec = vec![]; + let mut in_cursor = false; + for (i, raw) in manifest.lines().enumerate() { + let line = raw.trim(); + if line.starts_with('[') && !line.starts_with("[\"") { + in_cursor = line == "[[cursor]]"; + if in_cursor { + out.push(Entry { line: i + 1, id: None, fields: BTreeSet::new(), designer: true }); + } + continue; + } + let (Some(e), Some((key, value))) = (out.last_mut().filter(|_| in_cursor), raw.split_once('=')) else { continue }; + if raw.starts_with(char::is_whitespace) || key.trim().contains(' ') { + continue; + } + let key = key.trim(); + let value = value.trim().trim_matches('"'); + match key { + "id" => e.id = Some(value.to_string()), + "designer" => e.designer = value != "false", + _ => {} + } + e.fields.insert(key.to_string()); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const ENUM: &str = "pub enum Cursor {\n #[default]\n Arrow,\n /// Doc.\n RotateToward(u8),\n Pen,\n}\n"; + + fn entry(id: &str) -> String { + format!("[[cursor]]\nid = \"{id}\"\nshown_when = [\n \"One.\",\n]\nhotspot = \"Tip.\"\n\n") + } + + #[test] + fn a_complete_manifest_has_no_warnings() { + let m = [entry("Arrow"), entry("RotateToward"), entry("Pen"), "[[not_needed]]\nwhat = \"x\"\n".into()].concat(); + assert_eq!(check(ENUM, &m), Vec::::new()); + } + + #[test] + fn warns_about_missing_unknown_repeated_and_incomplete_entries() { + let m = [entry("Arrow"), entry("Arrow"), entry("Gone"), "[[cursor]]\nid = \"Pen\"\n".into()].concat(); + let w = check(ENUM, &m).join("\n"); + assert!(w.contains("Arrow is listed twice"), "{w}"); + assert!(w.contains("Gone is not a Cursor value"), "{w}"); + assert!(w.contains("Pen has no shown_when") && w.contains("Pen has no hotspot"), "{w}"); + assert!(w.contains("Cursor::RotateToward has no entry"), "{w}"); + } + + #[test] + fn reads_variants_in_order_and_entries_with_their_designer_flags() { + let v: Vec = variants(ENUM).into_iter().map(|v| v.name).collect(); + assert_eq!(v, ["Arrow", "RotateToward", "Pen"]); + let m = format!("app = \"TestCraft\"\n\n{}[[cursor]]\nid = \"Pen\"\ndesigner = false\n", entry("Arrow")); + let e: Vec<(Option, bool)> = entries(&m).into_iter().map(|e| (e.id, e.designer)).collect(); + assert_eq!(e, [(Some("Arrow".into()), true), (Some("Pen".into()), false)]); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 65f5ac5a1..b42e5af1c 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -7,6 +7,7 @@ mod affinity_corpus; mod assets; mod brands; mod bundle; +mod cursors; mod ico; mod layers; mod pdf_deps; @@ -25,10 +26,11 @@ usage: cargo xtask commands: assets check that every icon/image/font/asset is attributed in ASSETS.md brands check that user-visible text (labels, params docs, MCP, docs, packaging) names no other vendor's products + cursors warn about Cursor values with no entry in crates/tools/src/cursors.toml (never fails) pdf-deps verify immutable shared PDF writer and patched Krilla sources layers enforce the crate dependency layering (plan/architecture.md §3) wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates and the web app - ci fmt --check, clippy -D warnings, test, no-bundled-fonts, pdf-deps, assets, brands, layers, wasm (stops at first failure) + ci fmt --check, clippy -D warnings, test, no-bundled-fonts, pdf-deps, assets, brands, cursors (warnings only), layers, wasm (stops at first failure) corpus [--download] [--affinity] show where test corpora live; --download fetches PngSuite into corpus/pngsuite, --affinity the pinned public Affinity documents into corpus/affinity @@ -46,6 +48,7 @@ fn main() -> ExitCode { let result = match args.first().map(String::as_str) { Some("assets") => assets::run(&root()), Some("brands") => brands::run(&root()), + Some("cursors") => cursors::run(&root()), Some("layers") => cmd_layers(), Some("pdf-deps") => pdf_deps::run(&root()), Some("wasm") => cmd_wasm(), @@ -215,6 +218,7 @@ fn cmd_ci() -> Result<(), String> { ("pdf-deps", Box::new(|| pdf_deps::run(&root()))), ("assets", Box::new(|| assets::run(&root()))), ("brands", Box::new(|| brands::run(&root()))), + ("cursors", Box::new(|| cursors::run(&root()))), ("layers", Box::new(cmd_layers)), ("wasm", Box::new(cmd_wasm)), ]; From 8689863fbfaf7831aba7d39a9f242da2e82ee5db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabio=20William=20Concei=C3=A7=C3=A3o?= Date: Mon, 12 Oct 2026 09:40:58 +0100 Subject: [PATCH 005/208] Part of #394: Highlight Alternate Glyphs paints pink behind OpenType substitutions. --- crates/render/src/lib.rs | 56 +++++++++++++++++++++------- crates/render/src/tests_alternate.rs | 37 ++++++++++++++++++ crates/ui-egui/src/canvas.rs | 3 ++ crates/ui-egui/src/lib.rs | 2 + docs/gaps.md | 2 +- docs/mcp.md | 6 ++- docs/target-app-parity.md | 2 + docs/type-parity.md | 2 +- 8 files changed, 92 insertions(+), 18 deletions(-) create mode 100644 crates/render/src/tests_alternate.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 556e91693..5791bd49b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -123,6 +123,10 @@ pub struct RenderOptions { pub mask_view: Option, /// Screen view: highlight substituted fonts and glyphs as Document Setup asks. pub highlight_substitutions: bool, + /// Screen view: Preferences › Type › Highlight Alternate Glyphs. Pink behind glyphs OpenType + /// features substituted (ligatures, contextual forms), not Document Setup's missing-glyph + /// highlights. + pub highlight_alternate_glyphs: bool, /// Edge smoothing (raster export option). pub anti_alias: AntiAlias, /// Screen view: placed documents draw from cached bitmaps made in the background (see @@ -227,6 +231,7 @@ impl Default for RenderOptions { guide: ([0x4a, 0xd8, 0xff], false), mask_view: None, highlight_substitutions: false, + highlight_alternate_glyphs: false, anti_alias: AntiAlias::Art, progressive_placed: false, trace_views: false, @@ -1789,8 +1794,11 @@ impl Renderer { } if f.opts.highlight_substitutions { let setup = &f.doc.setup; - for (on, path) in [(setup.highlight_substituted_fonts, &g.substituted_fonts), (setup.highlight_substituted_glyphs, &g.substituted_glyphs)] - { + for (on, path) in [ + (setup.highlight_substituted_fonts, &g.substituted_fonts), + (setup.highlight_substituted_glyphs, &g.substituted_glyphs), + (f.opts.highlight_alternate_glyphs, &g.alternate_glyphs), + ] { if on && !path.elements().is_empty() { ctx.set_transform(xf); ctx.set_fill_rule(peniko::Fill::NonZero); @@ -2218,6 +2226,9 @@ struct TextGeom { /// font lacks (drawn from a fallback font): Document Setup's substitution highlights. substituted_fonts: BezPath, substituted_glyphs: BezPath, + /// Boxes of glyphs OpenType features substituted (ligatures, contextual forms): Preferences › + /// Highlight Alternate Glyphs. + alternate_glyphs: BezPath, /// Inline graphics placed by the layout. inlines: Vec, } @@ -2243,12 +2254,18 @@ impl TextGeom { /// Approximate heap bytes, for the cache's budget. fn bytes(&self) -> usize { size_of::() - + [&self.all, &self.substituted_fonts, &self.substituted_glyphs].into_iter().map(lru::path_bytes).sum::() + + [&self.all, &self.substituted_fonts, &self.substituted_glyphs, &self.alternate_glyphs].into_iter().map(lru::path_bytes).sum::() + self.spans.len() * size_of::<(usize, usize, usize)>() + self.inlines.len() * size_of::() } } +/// OpenType-substituted form: a ligature (several source characters → one glyph) or a glyph whose +/// id is not the face's cmap mapping for its first character (contextual / stylistic alternate). +pub(crate) fn is_opentype_alternate(nominal_gid: u32, shaped_gid: u32, cluster_chars: usize) -> bool { + shaped_gid != 0 && (cluster_chars > 1 || shaped_gid != nominal_gid) +} + fn text_geom(t: &TextObject) -> TextGeom { text_geom_snapped(t, None) } @@ -2288,30 +2305,39 @@ fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeom { None => (true, None), }) .collect(); - let (mut substituted_fonts, mut substituted_glyphs) = (BezPath::new(), BezPath::new()); + let (mut substituted_fonts, mut substituted_glyphs, mut alternate_glyphs) = (BezPath::new(), BezPath::new(), BezPath::new()); + let plain = t.plain_text(); for g in &layout.glyphs { push(&mut all, g.run, &g.outline); let Some(&(missing, face)) = faces.get(g.run) else { continue }; - let target = if missing { - &mut substituted_fonts - } else if face.is_some_and(|f| f != g.font_id) { - &mut substituted_glyphs - } else { - continue; - }; let Some(line) = layout.lines.get(g.line) else { continue }; let mut cell = Rect::new(g.origin.x, g.origin.y - line.ascent, g.origin.x + g.advance, g.origin.y + line.descent).to_path(0.1); cell.apply_affine(Affine::rotate_about(g.angle, g.origin)); - target.extend(cell.iter()); + if missing { + substituted_fonts.extend(cell.iter()); + continue; + } + if face.is_some_and(|f| f != g.font_id) { + substituted_glyphs.extend(cell.iter()); + continue; + } + // Same face: OpenType may have swapped the glyph (liga, calt, …). + let Some(cluster) = plain.get(g.byte..g.byte.saturating_add(g.len)) else { continue }; + let cluster_chars = cluster.chars().count(); + let Some(first) = cluster.chars().next() else { continue }; + let nominal = db.face_by_id(g.font_id).map(|f| f.glyph_for(first)).unwrap_or(0); + if is_opentype_alternate(nominal, g.gid, cluster_chars) { + alternate_glyphs.extend(cell.iter()); + } } // Underline and strikethrough bars, painted as their run's type is (#847). for (run, bar) in vectorcraft_text::decorations(&layout, db, t) { push(&mut all, run, &bar); } - TextGeom { all, spans, bounds: layout.bounds, substituted_fonts, substituted_glyphs, inlines: layout.inlines } + TextGeom { all, spans, bounds: layout.bounds, substituted_fonts, substituted_glyphs, alternate_glyphs, inlines: layout.inlines } } -/// Document Setup's highlight behind substituted fonts and glyphs (screen only). +/// Document Setup's and Preferences › Highlight Alternate Glyphs pink (screen only). const SUBSTITUTED: peniko::Color = peniko::Color::from_rgba8(255, 120, 190, 110); /// The pixel size of `region` rendered at `scale` pixels per point (at least 1×1). pub fn region_pixels(region: Rect, scale: f64) -> (u32, u32) { @@ -2367,6 +2393,8 @@ mod tests; #[cfg(test)] mod tests_adjust; #[cfg(test)] +mod tests_alternate; +#[cfg(test)] mod tests_blend; #[cfg(test)] mod tests_charstroke; diff --git a/crates/render/src/tests_alternate.rs b/crates/render/src/tests_alternate.rs new file mode 100644 index 000000000..6b9b628e9 --- /dev/null +++ b/crates/render/src/tests_alternate.rs @@ -0,0 +1,37 @@ +//! Preferences › Type › Highlight Alternate Glyphs (#394): OpenType substitutions get a pink +//! cell behind them on screen when the preference is on. + +use super::*; + +#[test] +fn is_opentype_alternate_marks_ligatures_and_forms() { + assert!(is_opentype_alternate(100, 100, 2), "ligature cluster"); + assert!(is_opentype_alternate(50, 99, 1), "contextual / stylistic form"); + assert!(!is_opentype_alternate(50, 50, 1), "nominal cmap glyph"); + assert!(!is_opentype_alternate(50, 0, 2), "missing glyph"); +} + +#[test] +fn highlight_alternate_glyphs_paints_pink_behind_a_ligature() { + use vectorcraft_doc::text::{CharStyle, TextObject}; + use vectorcraft_doc::{Node, NodeKind}; + use vectorcraft_geom::Point; + + // Source Serif 4 ships with standard ligatures for "fi". + let mut d = Document::new(200.0, 100.0); + let style = CharStyle { size: 48.0, font_family: "Source Serif 4".into(), ..Default::default() }; + let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 70.0), "fi", style)))); + n.id = d.alloc_id(); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + + let off = RenderOptions { background: Some([255, 255, 255, 255]), highlight_substitutions: true, highlight_alternate_glyphs: false, ..Default::default() }; + let on = RenderOptions { highlight_alternate_glyphs: true, ..off.clone() }; + let mut r = Renderer::new(); + let without = r.render(&d, 200, 100, Affine::IDENTITY, &off); + let with = r.render(&d, 200, 100, Affine::IDENTITY, &on); + assert_ne!(without.pixels, with.pixels, "the preference changes the frame"); + // Pink SUBSTITUTED = (255, 120, 190) at alpha 110, blended over white / ink. + let pinkish = with.pixels.chunks(4).filter(|p| p[0] > 200 && p[1] < 180 && p[2] > 150 && p[2] < 230).count(); + assert!(pinkish > 10, "pink highlight cells behind the ligature ({pinkish} pixels)"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index edd0e31f8..e6b6157f8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -302,6 +302,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { hidden: vec![], rot: v.rotation, anti_alias: app.session.prefs.anti_aliased_artwork, + highlight_alternate_glyphs: app.session.prefs.highlight_alternate_glyphs, placed: vectorcraft_render::placed_document::generation(), pixel, smooth_images, @@ -354,6 +355,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { guide: key.guide, mask_view, highlight_substitutions: true, + // Type › Highlight Alternate Glyphs (#394): pink behind OpenType substitutions. + highlight_alternate_glyphs: app.session.prefs.highlight_alternate_glyphs, // General › Anti-aliased Artwork: off, edges are hard on screen (raster effects and // pattern tiles stay smooth), as in Illustrator. anti_alias: if app.session.prefs.anti_aliased_artwork { vectorcraft_render::AntiAlias::Art } else { vectorcraft_render::AntiAlias::None }, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b6c58414f..bc1fe3d8a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -332,6 +332,8 @@ pub struct CacheKey { pub rot: f64, /// General › Anti-aliased Artwork. pub anti_alias: bool, + /// Type › Highlight Alternate Glyphs (#394). + pub highlight_alternate_glyphs: bool, /// [`vectorcraft_render::placed_document::generation`]: placed documents' bitmaps made since. pub placed: u64, /// View › Pixel Preview: the document pixels rendered (x0, y0, x1, y1), one per point, shown diff --git a/docs/gaps.md b/docs/gaps.md index 9d3e6e248..8cb39b77f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -193,8 +193,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G12 Preferences and panels that don't act yet +- **Missing:** ~14 Preferences options stored but not acted on (#394: Open - **Done:** quick Image Trace presets reveal Threshold and Advanced controls (Paths, Corners, Noise); Properties can reopen them. Other context-specific Properties gaps remain. - - **Missing:** ~15 Preferences options stored but not acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, devices…), Properties panel per context, context menus beyond the canvas, Layers panel row menus and Collect for Export, SVG Interactivity. diff --git a/docs/mcp.md b/docs/mcp.md index b6c980a1c..2be438c9d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1974,9 +1974,11 @@ their own Anti-aliasing option), `scrubNumericFields` (on: a horizontal drag on a numeric field's label steps the field, one undo step per drag; the control channel's `ui.drag` scrubs), `showHomeScreen` (on by default: the Home screen while no document is open; off, an empty window, and the Home button or `app.home` still shows the screen), `autoCollapseIconPanels` (off by -default: on, a click away from a panel popped out of the icon column puts it away) and `displayPrintSize` (off by +default: on, a click away from a panel popped out of the icon column puts it away), `displayPrintSize` (off by default: View › Actual Size / `view.actualSize` is one document point per screen point; on, one document inch fills -96 screen points, an inch at the system's reference density whatever the display scaling). `blackOnScreen` +96 screen points, an inch at the system's reference density whatever the display scaling). `highlightAlternateGlyphs` (on by default: the canvas paints a pink cell behind glyphs OpenType features +substituted — ligatures, contextual forms — on screen; Document Setup's Highlight Substituted Fonts / Glyphs stay +separate). `blackOnScreen` (Appearance of Black › On Screen, `accurate` by default) sets how the canvas shows pure CMYK black, black ink alone at 100%: `accurate` shows it through the CMYK profile, a dark grey, and `rich` shows it as RGB black. Rich blacks, black tints and images show the same either way, and exports are unchanged. `blackOutput` (Appearance of Black › diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6c95df813..025b1b6f0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -334,6 +334,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | 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) | 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 | +| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 80% | macOS native menu dismissal fixed (M14.8); Variables, Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done; remaining: SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas, about 13 Preferences options stored but not yet acted on (#394: Open Documents As Tabs, Real-time Drawing, hyphenation language, devices…) | 13–20 | | UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 80% | macOS native menu dismissal fixed (M14.8); Variables, Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done; remaining: SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas, about 14 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, devices…) | 13–20 | | Libraries, Links, Package | 2 | 82% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles (#745) and its search, groups and library export/import as `.vclibrary` files (#926) are done; Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 | | **Feature parity** | **103** | **~75%** | | **~210–330** | @@ -443,6 +444,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. - Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close). - Four brightness themes, persistent preferences. + - 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 Fonts / Glyphs stay separate; `prefs.set`, #394). About 14 stored options still do nothing yet (the rest of #394: East Asian Type units, 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. - 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). About 15 stored options still do nothing yet (the rest of #394: Highlight Alternate Glyphs, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Touch Workspace). - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). diff --git a/docs/type-parity.md b/docs/type-parity.md index dcbc5897c..61a930cb4 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -25,7 +25,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing | | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | -| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference unused (#394); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607) | +| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394) paints pink behind OpenType substitutions on screen; stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607) | | Font menu: samples, live preview, filters, favourites; font matching by any name; Find Font | done | #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) | | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | From 0ffe0c7c0e3d80cd0183b4b87736cba834e7cb19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabio=20William=20Concei=C3=A7=C3=A3o?= Date: Mon, 12 Oct 2026 11:46:51 +0100 Subject: [PATCH 006/208] Part of #394: Highlight Alternate Glyphs defaults to off. --- crates/engine/src/lib.rs | 2 +- crates/engine/src/tests_prefs.rs | 12 ++++++++++++ crates/mcp/src/tests.rs | 15 +++++++++++++++ docs/mcp.md | 6 +++++- docs/target-app-parity.md | 1 + docs/type-parity.md | 2 +- 6 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ced1ccef7..21131638c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1007,7 +1007,7 @@ impl Default for Prefs { font_preview_size: s("medium"), recent_fonts_count: 10, missing_glyph_protection: true, - highlight_alternate_glyphs: true, + highlight_alternate_glyphs: false, placeholder_text: true, units_general: s("points"), units_stroke: s("points"), diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index e97d3bfa4..e4b0645ec 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -1286,6 +1286,18 @@ fn show_font_names_in_english_preference_is_stored() { assert!(s.prefs.font_names_in_english); } +/// Preferences › Type › Highlight Alternate Glyphs (#394): off by default so OpenType +/// substitutions are not pink on every canvas until the user turns the preference on. +#[test] +fn highlight_alternate_glyphs_preference_is_off_by_default() { + let mut s = Session::new(); + assert!(!s.prefs.highlight_alternate_glyphs, "off by default"); + set_pref(&mut s, "highlightAlternateGlyphs", json!(true)); + assert!(s.prefs.highlight_alternate_glyphs); + set_pref(&mut s, "highlightAlternateGlyphs", json!(false)); + assert!(!s.prefs.highlight_alternate_glyphs); +} + /// Preferences › Hyphenation › Exceptions (#394): `prefs.set` pushes the list into the hyphenator /// and bumps open documents so type reflows. Parsing of the list is covered in `vectorcraft-text`. #[test] diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index b28a0630e..13c9e964b 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1072,6 +1072,21 @@ fn show_font_names_in_english_does_not_change_font_list_for_agents() { assert!(families.iter().any(|f| f.as_str() == Some("Source Sans 3")), "{families:?}"); } +/// Preferences › Type › Highlight Alternate Glyphs (#394): off by default so the canvas stays +/// clear until an agent (or the Preferences dialog) turns it on. Headless MCP has no canvas. +#[test] +fn highlight_alternate_glyphs_pref_round_trips_for_agents() { + let mut s = server(); + let r = call(&mut s, 1, "run_command", json!({"command": "prefs.get", "params": {"key": "highlightAlternateGlyphs"}})); + assert_eq!(r["isError"], false, "{r}"); + assert_eq!(serde_json::from_str::(&text_of(&r)).unwrap(), json!(false)); + let r = call(&mut s, 2, "run_command", json!({"command": "prefs.set", "params": {"key": "highlightAlternateGlyphs", "value": true}})); + assert_eq!(r["isError"], false, "{r}"); + let r = call(&mut s, 3, "run_command", json!({"command": "prefs.get", "params": {"key": "highlightAlternateGlyphs"}})); + assert_eq!(r["isError"], false, "{r}"); + assert_eq!(serde_json::from_str::(&text_of(&r)).unwrap(), json!(true)); +} + /// Type preferences reach agents (#394): type the Type tool places starts with placeholder text, /// selected; Alt+→ tracks it by Tracking and Cmd+Shift+. steps its size by Size/Leading. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 2be438c9d..621301a76 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1976,7 +1976,7 @@ control channel's `ui.drag` scrubs), `showHomeScreen` (on by default: the Home s off, an empty window, and the Home button or `app.home` still shows the screen), `autoCollapseIconPanels` (off by default: on, a click away from a panel popped out of the icon column puts it away), `displayPrintSize` (off by default: View › Actual Size / `view.actualSize` is one document point per screen point; on, one document inch fills -96 screen points, an inch at the system's reference density whatever the display scaling). `highlightAlternateGlyphs` (on by default: the canvas paints a pink cell behind glyphs OpenType features +96 screen points, an inch at the system's reference density whatever the display scaling). `highlightAlternateGlyphs` (off by default: on, the canvas paints a pink cell behind glyphs OpenType features substituted — ligatures, contextual forms — on screen; Document Setup's Highlight Substituted Fonts / Glyphs stay separate). `blackOnScreen` (Appearance of Black › On Screen, `accurate` by default) sets how the canvas shows pure CMYK black, black ink alone @@ -2088,11 +2088,15 @@ Pen's second anchor at (150, 431), in line with the square's centre. - `hyphenationExceptions` (empty by default): Preferences › Hyphenation › Exceptions. A comma-separated list of words, matched without regard to case: a word with no hyphens is never hyphenated (`typography`), and a word with hyphens breaks only where they are (`hap-pen`). `prefs.set` pushes the list into the hyphenator and reflows open documents. +- `highlightAlternateGlyphs` (off by default): Preferences › Type › Highlight Alternate Glyphs. On, the canvas paints + pink behind OpenType substitutions (ligatures, contextual forms). Document Setup's Highlight Substituted Fonts / + Glyphs stay separate. Agents set it with `prefs.set`; the desktop canvas reads it on the next paint. ```json {"name":"run_command","arguments":{"command":"prefs.set","params":{"values":{"typeSizeIncrement":4,"trackingIncrement":50}}}} {"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"fontNamesInEnglish","value":false}}} {"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"hyphenationExceptions","value":"typography, hap-pen"}}} +{"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"highlightAlternateGlyphs","value":true}}} {"name":"run_command","arguments":{"command":"type.step","params":{"attribute":"tracking","by":-2}}} ``` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 025b1b6f0..4dbf73a8e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -444,6 +444,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. - Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close). - Four brightness themes, persistent preferences. + - 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 Fonts / Glyphs stay separate; `prefs.set`, #394). About 14 stored options still do nothing yet (the rest of #394: East Asian Type units, 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 Fonts / Glyphs stay separate; `prefs.set`, #394). About 14 stored options still do nothing yet (the rest of #394: East Asian Type units, 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. - 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). About 15 stored options still do nothing yet (the rest of #394: Highlight Alternate Glyphs, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Touch Workspace). diff --git a/docs/type-parity.md b/docs/type-parity.md index 61a930cb4..ae3af354d 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -25,7 +25,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing | | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | -| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394) paints pink behind OpenType substitutions on screen; stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607) | +| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on; stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607) | | Font menu: samples, live preview, filters, favourites; font matching by any name; Find Font | done | #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) | | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | From 52771905e6d32ebfeb8d34bebb1c4348ed7949b2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 12:20:28 +0700 Subject: [PATCH 007/208] Part of #394: rustfmt the alternate-glyph test --- crates/render/src/tests_alternate.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/render/src/tests_alternate.rs b/crates/render/src/tests_alternate.rs index 6b9b628e9..dd4249700 100644 --- a/crates/render/src/tests_alternate.rs +++ b/crates/render/src/tests_alternate.rs @@ -25,7 +25,12 @@ fn highlight_alternate_glyphs_paints_pink_behind_a_ligature() { let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); - let off = RenderOptions { background: Some([255, 255, 255, 255]), highlight_substitutions: true, highlight_alternate_glyphs: false, ..Default::default() }; + let off = RenderOptions { + background: Some([255, 255, 255, 255]), + highlight_substitutions: true, + highlight_alternate_glyphs: false, + ..Default::default() + }; let on = RenderOptions { highlight_alternate_glyphs: true, ..off.clone() }; let mut r = Renderer::new(); let without = r.render(&d, 200, 100, Affine::IDENTITY, &off); From f31d91bf1a67de67cb6012c193151cde3b990490 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 23:17:35 +0200 Subject: [PATCH 008/208] Layers: a .ai another app wrote opens with its top-level layers closed, as its groups (fixes #1475) --- crates/engine/src/cmd/fileio/load.rs | 5 +++ .../engine/src/cmd/fileio/tests_aiimport.rs | 40 +++++++++++++++++++ crates/engine/src/lib.rs | 2 +- 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 58200162c..3834aa7e1 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -377,6 +377,11 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option (Vec, Vec) { + let st = s.doc().unwrap(); + let (mut rows, mut open) = (vec![], vec![]); + st.doc.walk(|n| { + if n.children().is_some() { + rows.push(n.name.clone().unwrap_or_default()); + if st.layers_open.contains(n.id) { + open.push(n.name.clone().unwrap_or_default()); + } + } + }); + (rows, open) +} + +/// #1475: a `.ai` another app wrote keeps no open rows VectorCraft reads, so it opens with every +/// row closed, its top-level layers as its groups; a `.ai` VectorCraft wrote, and other formats, +/// open as before (with their top-level layers open unless the file says otherwise). +#[test] +fn an_illustrator_ai_file_opens_with_its_layers_closed() { + let mut s = Session::new(); + for file in [ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()), ai::eps(&ai::sample_data(), ai::page_ps())] { + let r = open(&mut s, "art.ai", &file, json!({})).unwrap(); + assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(false)), "{r}"); + let (rows, open) = open_rows(&s); + assert!(rows.contains(&"Art".into()) && rows.contains(&"Pair".into()) && rows.contains(&"Sub".into()), "{rows:?}"); + assert_eq!(open, Vec::::new(), "{r}"); + } + // The same art as an EPS: its top-level layers open. + open(&mut s, "art.eps", &ai::eps(&ai::sample_data(), ai::page_ps()), json!({})).unwrap(); + assert_eq!(open_rows(&s).1, ["Art", "Hidden"]); + // Saved as .ai by VectorCraft, it opens with the rows it was saved with. + let saved = s.execute("document.save", &json!({"format": "ai"})).unwrap(); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + let r = open(&mut s, "again.ai", &bytes, json!({})).unwrap(); + assert_eq!(r["restored"], json!(true), "{r}"); + assert_eq!(open_rows(&s).1, ["Art", "Hidden"]); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 21131638c..a16fc502e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -345,7 +345,7 @@ static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64 /// The rows open in the Layers panel: the layers, sublayers and groups that show what they hold. /// A document opens with the ones it was saved with ([`Document::layers_open`]), else with only -/// its top-level layers open. +/// its top-level layers open (a `.ai` another app wrote opens with none open). #[derive(Clone, Debug, Default)] pub struct OpenRows { ids: std::collections::HashSet, From 6b0d1ed1c026782a927d8d43b1e061c1135e1e26 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 17:43:02 -0700 Subject: [PATCH 009/208] Crop Image: trim checks the image's RGBA size before decoding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crop Image's trim (object.cropImage {trim: true}) decoded the image within MAX_RASTER_ALLOC (2 GB) and then converted it to RGBA. The decoder holds the decoded image, before conversion, to that allowance, so a gray or 1-bit image within it could take up to four times as much once converted. Trim now reads the image's size from its header first and, when width × height × 4 is over the allowance, fails before anything is decoded, with the check and message Load Texture uses: "an image of W × H pixels is too large to draw". Images within the allowance are trimmed as before. --- crates/engine/src/cmd/menucmds.rs | 15 ++++++++++++--- crates/engine/src/tests_linkspanel.rs | 2 +- crates/engine/src/tests_menucmds.rs | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 465e38851..23e49a039 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -664,9 +664,18 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "trimmed": false })); } // Print rasters run to gigabytes decoded: the same allowance opening them has. - let mut reader = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice())) - .with_guessed_format() - .map_err(|_| bad(C, "the image's pixels can't be read"))?; + let reader = || { + image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice())) + .with_guessed_format() + .map_err(|_| bad(C, "the image's pixels can't be read")) + }; + // The image's size as RGBA, read from its header, is held to that allowance before anything + // is decoded (#1717). + let (w, h) = reader()?.into_dimensions().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?; + if u64::from(w) * u64::from(h) * 4 > crate::cmd::fileio::MAX_RASTER_ALLOC { + return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw"))); + } + let mut reader = reader()?; let mut limits = image::Limits::default(); limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC); reader.limits(limits); diff --git a/crates/engine/src/tests_linkspanel.rs b/crates/engine/src/tests_linkspanel.rs index fc0de2a8b..2899ce021 100644 --- a/crates/engine/src/tests_linkspanel.rs +++ b/crates/engine/src/tests_linkspanel.rs @@ -19,7 +19,7 @@ fn place_embedded(s: &mut Session, path: &str) -> NodeId { /// A PNG that says it is `w` × `h` 1-bit grey pixels (its header and no rows, which is all that /// placing and listing read). -fn png_header(w: u32, h: u32) -> Vec { +pub(crate) fn png_header(w: u32, h: u32) -> Vec { let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); let ihdr: Vec = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat(); for chunk in [&ihdr[..], b"IDAT\x78\x9c", b"IEND"] { diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 410e3aac0..22642ec0c 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -2423,6 +2423,24 @@ fn crop_image_trim_cuts_to_the_opaque_pixels_as_they_are() { assert_eq!(bounds(&s, img), b); } +#[test] +fn crop_image_trim_checks_the_rgba_size_before_decoding() { + // 1-bit gray PNGs, header only: their samples are within the decoder's limit, and as RGBA + // (4 bytes a pixel) they are just over or just within the 2 GB allowance. + let trim = |w: u32, h: u32| { + let png = super::tests_linkspanel::png_header(w, h); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "wall.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.cropImage", &json!({"trim": true})).unwrap_err().to_string() + }; + let over = trim(23_171, 23_170); + assert!(over.contains("an image of 23171 × 23170 pixels is too large to draw"), "{over}"); + // One column fewer is within the allowance: it is decoded, and its missing rows can't be read. + let within = trim(23_170, 23_170); + assert!(within.contains("the image's pixels can't be read"), "{within}"); +} + #[test] fn crop_image_trim_leaves_a_cmyk_image_alone() { // CMYK images keep their ink amounts and have no alpha: nothing to cut, and not an error. From 0f987812b0d21e3bcaba7a689ca3170a5cbd3ebb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 06:23:13 -0700 Subject: [PATCH 010/208] Render: check an image's RGBA size before decoding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer decoded each image within its 2 GB allowance (512 MB in the web build) and then converted it to RGBA. The decoder holds the decoded image, before conversion, to that allowance, so an image stored with fewer than four bytes a pixel (gray, 1-bit, gray with alpha, or RGB without alpha) could take up to four times as much (4/3 times for RGB) once converted, and as much again as the pixmap drawn from it. The renderer now reads the image's size from its header first. When width × height × 4 is over the allowance it does not decode the image and handles it as one it can't decode: the renderer draws nothing for it, and a raster export warns that an image is missing. Images within the allowance are decoded and drawn as before. --- crates/render/src/paint.rs | 28 ++++++++++++---- crates/render/src/tests.rs | 67 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 427d39d8a..d021a8f27 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -65,20 +65,34 @@ pub fn decode_failures() -> u64 { DECODE_FAILURES.load(std::sync::atomic::Ordering::Relaxed) } -/// `bytes` decoded within [`MAX_DECODE_ALLOC`]; a failure is counted in [`decode_failures`]. +/// `bytes` decoded within [`MAX_DECODE_ALLOC`] ([`decode_within_allowance`]); a failure is counted +/// in [`decode_failures`]. fn decode(bytes: &[u8]) -> Option { - let img = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok().and_then(|mut r| { - let mut limits = image::Limits::default(); - limits.max_alloc = Some(MAX_DECODE_ALLOC); - r.limits(limits); - r.decode().ok() - }); + let img = decode_within_allowance(bytes).ok(); if img.is_none() { DECODE_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } img } +/// `bytes` decoded within [`MAX_DECODE_ALLOC`], counted as RGBA: the image's size is read from its +/// header first, and an image of more than that as RGBA gets a limit error before anything is +/// decoded (#1717). +pub(crate) fn decode_within_allowance(bytes: &[u8]) -> image::ImageResult { + let reader = || -> image::ImageResult<_> { + let mut r = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(MAX_DECODE_ALLOC); + r.limits(limits); + Ok(r) + }; + let (w, h) = reader()?.into_dimensions()?; + if u64::from(w) * u64::from(h) > MAX_DECODE_ALLOC / 4 { + return Err(image::ImageError::Limits(image::error::LimitError::from_kind(image::error::LimitErrorKind::InsufficientMemory))); + } + reader()?.decode() +} + /// `img` shrunk, keeping its proportions, to fit a pixmap's sides (at most `u16::MAX` pixels). fn fit_pixmap(img: image::DynamicImage) -> image::DynamicImage { let (w, h) = (img.width(), img.height()); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index a5e32ce0d..70b8a4559 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -799,6 +799,73 @@ fn images_take_their_nearest_pixel_without_smoothing() { assert_eq!(greys(false), 0, "nearest: black, then white"); } +/// A PNG that says it is `w` × `h` 1-bit gray pixels (its header and no rows). +fn png_header(w: u32, h: u32) -> Vec { + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + let ihdr: Vec = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat(); + for chunk in [&ihdr[..], b"IDAT\x78\x9c", b"IEND"] { + out.extend((chunk.len() as u32 - 4).to_be_bytes()); + out.extend(chunk); + let mut crc = flate2::Crc::new(); + crc.update(chunk); + out.extend(crc.sum().to_be_bytes()); + } + out +} + +/// A little-endian TIFF that says it is `w` × `h` 8-bit gray pixels in one strip (its header and +/// no pixels). +fn tiff_header(w: u32, h: u32) -> Vec { + let mut out = b"II*\0".to_vec(); + out.extend(8u32.to_le_bytes()); + // (tag, type: 3 SHORT or 4 LONG, value), one value each. + let entries: [(u16, u16, u32); 9] = + [(256, 4, w), (257, 4, h), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 8), (277, 3, 1), (278, 4, h), (279, 4, 0)]; + out.extend((entries.len() as u16).to_le_bytes()); + for (tag, kind, value) in entries { + out.extend(tag.to_le_bytes()); + out.extend(kind.to_le_bytes()); + out.extend(1u32.to_le_bytes()); + out.extend(value.to_le_bytes()); + } + out.extend(0u32.to_le_bytes()); + out +} + +/// The decode allowance (2 GB) counts an image's pixels as RGBA, read from its header (#1717): a +/// 1-bit gray image whose 8-bit gray pixels are within it but whose RGBA is over it is not decoded +/// and draws nothing, counted for an export to report; one a column narrower goes on to decoding. +#[test] +fn an_image_too_large_as_rgba_is_not_decoded() { + let over = paint::decode_within_allowance(&png_header(23_171, 23_170)); + assert!(matches!(over, Err(image::ImageError::Limits(_))), "{over:?}"); + let failed = decode_failures(); + assert!(paint::decode_pixmap(&png_header(23_171, 23_170)).is_none()); + assert!(decode_failures() > failed); + // Within the allowance: decoded, and its missing rows can't be read. + let within = paint::decode_within_allowance(&png_header(23_170, 23_170)); + assert!(matches!(within, Err(image::ImageError::Decoding(_))), "{within:?}"); + // A header whose size as RGBA does not fit in 64 bits gets the same limit error. + let huge = paint::decode_within_allowance(&tiff_header(1 << 31, 1 << 31)); + assert!(matches!(huge, Err(image::ImageError::Limits(_))), "{huge:?}"); +} + +/// A gray image within the allowance draws its gray levels. +#[test] +fn a_gray_image_draws_its_gray_levels() { + use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind}; + let img = image::GrayImage::from_fn(2, 1, |x, _| image::Luma([if x == 0 { 40 } else { 200 }])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut d = Document::new(40.0, 20.0); + d.images.insert("gray".into(), ImageBlob::new("image/png", png)); + let id = d.alloc_id(); + let im = ImageObject { key: "gray".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default() }; + d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); + let r = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images: false, ..Default::default() }); + assert_eq!((r.pixel(5, 10), r.pixel(35, 10)), ([40, 40, 40, 255], [200, 200, 200, 255])); +} + /// Art Optimized exports are supersampled (#983): two red shapes meeting along a diagonal leave no /// seam of the white background between them, where shape-by-shape smoothing shows one; the outer /// edges stay smooth. From 9bee299dd5326fb1578d28985949db331971fc01 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 19:30:26 -0700 Subject: [PATCH 011/208] Render: an export no longer warns about images another thread could not decode A raster export compared the process-wide count of images that could not be decoded before and after it drew, and warned that "an image couldn't be decoded and is missing from the picture" when the count rose. Any other thread that failed to decode an image in that time raised the count too: a second export, the UI thread drawing the canvas, or the canvas, thumbnail and placed-document workers drawing a document with such an image. The export then warned about an image it does not contain. In the engine's tests, options_not_applied_yet_and_document_features_warn failed in 5 of 60 runs of the tests_raster and tests_pdf* tests. The count is now kept per thread. An export reads it on the thread it draws on. That thread decodes every image the export draws that is not already in the shared image store, and an image that failed to decode is never stored. --- crates/engine/src/cmd/fileio/tests_raster.rs | 47 ++++++++++++++++++++ crates/render/src/paint.rs | 16 ++++--- crates/render/src/tests.rs | 17 +++++++ 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio/tests_raster.rs b/crates/engine/src/cmd/fileio/tests_raster.rs index 53b35204c..110847ed5 100644 --- a/crates/engine/src/cmd/fileio/tests_raster.rs +++ b/crates/engine/src/cmd/fileio/tests_raster.rs @@ -217,6 +217,53 @@ fn an_image_opens_at_the_size_its_resolution_declares() { } } +#[test] +fn a_raster_export_says_nothing_of_images_another_thread_could_not_decode() { + // While another thread keeps exporting a document whose image can't be decoded (two exports at + // once, or the canvas drawing such a document), PNG exports of art with no image report + // nothing, and every export on the other thread still reports its own image. + use std::sync::atomic::{AtomicBool, Ordering}; + use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind}; + let mut broken = vectorcraft_doc::Document::new(40.0, 20.0); + broken.images.insert("broken".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); + let im = ImageObject { + key: "broken".into(), + width: 2, + height: 1, + xf: vectorcraft_geom::Affine::scale(20.0), + link: None, + placement: Default::default(), + }; + let (id, layer) = (broken.alloc_id(), broken.layers[0].id); + broken.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); + let mut s = session(400.0, 400.0, 1); + s.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 360, "height": 360})).unwrap(); + let says_so = |w: &[Value]| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))); + let stop = AtomicBool::new(false); + let (tx, rx) = std::sync::mpsc::channel(); + // Nothing in the scope panics, so the other thread is always told to stop. + let (theirs, ours) = std::thread::scope(|scope| { + let other = scope.spawn(|| { + let mut warned = vec![]; + while !stop.load(Ordering::Relaxed) { + let w = encode_with_warnings(&broken, "png", &json!({})).map(|(_, w)| w.into_iter().map(Value::from).collect::>()); + warned.push(w.is_ok_and(|w| says_so(&w))); + let _ = tx.send(()); + } + warned + }); + let started = rx.recv_timeout(std::time::Duration::from_secs(60)).is_ok(); + let ours: Vec> = (0..5) + .map(|_| s.execute("document.export", &json!({"format": "png"})).ok().map(|r| says_so(r["warnings"].as_array().map_or(&[], |w| w)))) + .collect(); + stop.store(true, Ordering::Relaxed); + (other.join().ok(), started.then_some(ours)) + }); + let theirs = theirs.expect("the other thread's exports"); + assert!(!theirs.is_empty() && theirs.iter().all(|w| *w), "the other thread's exports: {theirs:?}"); + assert_eq!(ours.expect("the other thread started"), vec![Some(false); 5], "exports with no image"); +} + #[test] fn a_raster_export_says_when_an_image_could_not_be_decoded() { // A PNG whose header reads (so it places at its size) but whose pixel data is cut short: diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index d021a8f27..7a375462b 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -56,13 +56,17 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr /// 6614 x 59055 px, is 1.56 GB as RGBA), past the decoder's 512 MB default. The web keeps it. const MAX_DECODE_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 }; -/// Images that couldn't be decoded (and so drew nothing), counted for exports to report. -static DECODE_FAILURES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +thread_local! { + /// Images that couldn't be decoded (and so drew nothing) on this thread, counted for exports to + /// report. + static DECODE_FAILURES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} -/// How many images have failed to decode so far (process-wide): an export compares the count -/// before and after to say it left images out. +/// How many images have failed to decode on this thread so far: an export compares the count +/// before and after it draws to say it left images out. Images that other threads (another export, +/// the canvas, thumbnails) fail to decode meanwhile are not counted. pub fn decode_failures() -> u64 { - DECODE_FAILURES.load(std::sync::atomic::Ordering::Relaxed) + DECODE_FAILURES.with(std::cell::Cell::get) } /// `bytes` decoded within [`MAX_DECODE_ALLOC`] ([`decode_within_allowance`]); a failure is counted @@ -70,7 +74,7 @@ pub fn decode_failures() -> u64 { fn decode(bytes: &[u8]) -> Option { let img = decode_within_allowance(bytes).ok(); if img.is_none() { - DECODE_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + DECODE_FAILURES.with(|n| n.set(n.get() + 1)); } img } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 70b8a4559..57e0e5304 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -776,6 +776,23 @@ fn invalid_dash_pattern_strokes_solid() { } } +/// An image that can't be decoded is counted on the thread that tried to decode it: an export +/// compares [`decode_failures`] before and after it draws, so another thread's failure meanwhile is +/// not its own. +#[test] +fn decode_failures_counts_this_threads_images_only() { + let undecodable = || paint::decode_pixmap(b"\x89PNG\r\n\x1a\n").is_none(); + let before = decode_failures(); + let other = std::thread::spawn(move || { + let start = decode_failures(); + undecodable() && decode_failures() == start + 1 + }); + assert!(other.join().unwrap(), "counted on the other thread"); + assert_eq!(decode_failures(), before, "the other thread's failure"); + assert!(undecodable()); + assert_eq!(decode_failures(), before + 1, "this thread's failure"); +} + /// An image drawn larger than its pixels samples smoothly, or with `smooth_images` off each screen /// pixel takes its nearest image pixel (Pixel Preview with File Handling › Display Bitmaps as /// Anti-aliased Images off, #394). From db2bbcf405b03206d95d1c7a84dbb36bd98fc433 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 08:21:24 -0700 Subject: [PATCH 012/208] Images: sampling and recoloring check an image's RGBA size without overflowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before the Eyedropper samples an image, or Edit Colors, Recolor Artwork or a color adjustment effect recolors an embedded one, `rgba_within` reads the image's size from its header and checks its size as RGBA against the allowance. The check multiplied width × height × 4 in 64 bits. A TIFF's sides go up to 2^32 - 1 pixels, so a header of 2^31 × 2^31 pixels overflowed that product: debug and test builds panicked with "attempt to multiply with overflow", and release builds wrapped it to 0 before the decoder's own 512 MB limit refused the image. The check now compares width × height with the allowance divided by 4. Every size whose RGBA bytes fit in 64 bits gets the same answer as before, and a larger one is refused. --- crates/doc/src/pixels.rs | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/pixels.rs b/crates/doc/src/pixels.rs index 525014fc0..e63a45bea 100644 --- a/crates/doc/src/pixels.rs +++ b/crates/doc/src/pixels.rs @@ -16,7 +16,7 @@ pub const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } /// is on the file's samples, and a grey image is a quarter of what it becomes here (#1717). fn rgba_within(bytes: &[u8], max: u64) -> Option { let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?; - if u64::from(w) * u64::from(h) * 4 > max { + if u64::from(w) * u64::from(h) > max / 4 { return None; } Some(image::load_from_memory(bytes).ok()?.to_rgba8()) @@ -143,6 +143,38 @@ mod tests { assert!(rgba_within(b"not an image", u64::MAX).is_none()); } + /// A little-endian TIFF whose header gives `w` × `h` 8-bit gray pixels in one strip. The file holds + /// no pixel data. + fn tiff_header(w: u32, h: u32) -> Vec { + let mut out = b"II*\0".to_vec(); + out.extend(8u32.to_le_bytes()); + // Each entry is (tag, type: 3 SHORT or 4 LONG, value) and holds one value. + let entries: [(u16, u16, u32); 9] = + [(256, 4, w), (257, 4, h), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 8), (277, 3, 1), (278, 4, h), (279, 4, 0)]; + out.extend((entries.len() as u16).to_le_bytes()); + for (tag, kind, value) in entries { + out.extend(tag.to_le_bytes()); + out.extend(kind.to_le_bytes()); + out.extend(1u32.to_le_bytes()); + out.extend(value.to_le_bytes()); + } + out.extend(0u32.to_le_bytes()); + out + } + + /// A TIFF's sides go up to 2^32 - 1 pixels: a size whose RGBA bytes don't fit in 64 bits is + /// refused like any other past the allowance, and an image within the allowance still decodes. + #[test] + fn a_size_past_64_bits_as_rgba_is_not_decoded() { + let huge = tiff_header(1 << 31, 1 << 31); + assert!(rgba_within(&huge, MAX_RASTER_ALLOC).is_none()); + assert!(rgba_within(&huge, u64::MAX).is_none()); + let gray = image::GrayImage::from_raw(4, 2, vec![7; 8]).unwrap(); + let mut png = Vec::new(); + gray.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + assert_eq!(rgba_within(&png, 35).map(|i| i.dimensions()), Some((4, 2)), "32 bytes as RGBA, within 35"); + } + #[test] fn samples_a_pixel_or_the_average_around_it() { let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 0]], 4, 1); From 0af1741e24bac24f0bf2774f8bca13b3c04670b2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 12:48:36 +0700 Subject: [PATCH 013/208] Crop Image: the RGBA size check can't overflow on a huge header --- crates/engine/src/cmd/menucmds.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 23e49a039..464def21b 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -672,7 +672,7 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { // The image's size as RGBA, read from its header, is held to that allowance before anything // is decoded (#1717). let (w, h) = reader()?.into_dimensions().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?; - if u64::from(w) * u64::from(h) * 4 > crate::cmd::fileio::MAX_RASTER_ALLOC { + if u64::from(w) * u64::from(h) > crate::cmd::fileio::MAX_RASTER_ALLOC / 4 { return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw"))); } let mut reader = reader()?; From 3d2e8bbe82e77d83fc55eb4be15324089fecf90a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 05:21:05 -0700 Subject: [PATCH 014/208] PDF export: Add Around is clamped when a file opens, and art too large for f64 gets no effect image document.rasterEffectsSettings accepts addAround from 0 to 1,000 pt, but a file's raster_effects.addAround opened as it was saved. With addAround 1e300, the area of a raster effect image overflowed f64, the image scale became 0 and the image got the transform [inf, NaN, NaN, inf, x, y]. PDF export left that image out, so the drop shadow was missing. A file's addAround now opens clamped to 0 to 1,000 pt. An effect image whose area or scale is not finite is not made. Export then keeps the object with its effects, and Expand Appearance expands it without an image, as when its PNG can't be encoded. Docs: docs/mcp.md (addAround). --- crates/engine/src/cmd/rasterfx.rs | 12 ++++- crates/engine/src/tests_rastersettings.rs | 55 +++++++++++++++++++++++ crates/format/src/lib.rs | 1 + docs/mcp.md | 5 ++- 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index fc3170464..3bab565d0 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -170,6 +170,8 @@ pub(crate) fn effects_scale(doc: &Document) -> f64 { /// fills' and strokes' raster effects included). With `knockout`, the coverage of that art is knocked out /// of the image, which then only holds what the effects add around it (a shadow to go under the /// vector object). The document's raster effects settings add room around it and finish its pixels. +/// `None` when it paints nothing, when the area it covers overflows `f64` or the scale is not +/// positive, or when its PNG can't be encoded. pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&Node>, scale: f64) -> Option { // What it paints (its members' effects included), and how far its fills' and strokes' raster // effects reach beyond that. @@ -177,8 +179,16 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N let look = out.raster_effects.clone(); let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(0.0, f64::max) + 2.0 + look.add_around; let b = b.inflate(reach, reach); + // An area too large for f64 gives a zero scale and an image transform that is not finite. + let area = b.width() * b.height(); + if !area.is_finite() { + return None; + } // Whole pixels, and no larger than 64 Mpx. - let scale = scale.min((64.0e6 / (b.width() * b.height()).max(1.0)).sqrt()); + let scale = scale.min((64.0e6 / area.max(1.0)).sqrt()); + if !(scale.is_finite() && scale > 0.0) { + return None; + } let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale); // Anti-alias off: hard edges (the effects themselves stay smooth). let anti_alias = if look.anti_alias { AntiAlias::Art } else { AntiAlias::None }; diff --git a/crates/engine/src/tests_rastersettings.rs b/crates/engine/src/tests_rastersettings.rs index 577c801cd..b0d20753b 100644 --- a/crates/engine/src/tests_rastersettings.rs +++ b/crates/engine/src/tests_rastersettings.rs @@ -2,6 +2,7 @@ //! raster effect images they make (PDF export, Expand Appearance) and the Rasterize defaults. use serde_json::{Value, json}; +use vectorcraft_doc::rastersettings::MAX_ADD_AROUND; use vectorcraft_doc::{Background, Document, NodeId, NodeKind, RasterColorModel, RasterEffectsSettings}; use super::*; @@ -98,6 +99,60 @@ fn add_around_grows_the_effect_image() { assert_eq!((after.width() - before.width(), after.height() - before.height()), (72, 72)); } +/// The transforms of the images in `doc` flattened for output. +fn image_transforms(doc: &Document) -> Vec<[f64; 6]> { + let mut xfs = vec![]; + crate::flatten_raster_effects(doc).unwrap().walk(|n| { + if let NodeKind::Image(im) = &n.kind { + xfs.push(im.xf.as_coeffs()); + } + }); + xfs +} + +#[test] +fn a_file_with_add_around_out_of_range_opens_with_it_in_range() { + let mut s = session(); + shadowed(&mut s); + let mut d = (*s.doc().unwrap().doc).clone(); + d.raster_effects.add_around = 1e300; + let back = vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap(); + assert_eq!(back.raster_effects.add_around, MAX_ADD_AROUND); + let xfs = image_transforms(&back); + assert!(!xfs.is_empty() && xfs.iter().flatten().all(|c| c.is_finite()), "{xfs:?}"); + // 1,000 pt on every side at 72 ppi. + assert_eq!(effect_image(&back).width() - effect_image(&s.doc().unwrap().doc).width(), 2000); + d.raster_effects.add_around = -5.0; + assert_eq!(vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap().raster_effects.add_around, 0.0); +} + +#[test] +fn art_too_large_for_f64_gets_no_effect_image() { + let mut s = session(); + let r = shadowed(&mut s); + // Settings out of range in a document made without opening a file. + let mut d = (*s.doc().unwrap().doc).clone(); + d.raster_effects.add_around = 1e300; + assert_eq!(image_transforms(&d), Vec::<[f64; 6]>::new()); + let mut d = (*s.doc().unwrap().doc).clone(); + let NodeKind::Path { path, live, .. } = &mut d.node_mut(r).unwrap().kind else { panic!("a path") }; + (*path, *live) = (vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(-1e300, -1e300, 1e300, 1e300)), None); + assert_eq!(image_transforms(&d), Vec::<[f64; 6]>::new()); + // Expand Appearance of it by id, with nothing selected, puts no image into the document (the + // rectangle at its own size gets one). + let normal = (*s.doc().unwrap().doc).clone(); + let mut expanded_images = |d: Document| { + let st = s.doc_mut().unwrap(); + (st.doc, st.selection) = (std::sync::Arc::new(d), Default::default()); + s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap(); + let mut images = 0; + s.doc().unwrap().doc.walk(|n| images += usize::from(matches!(n.kind, NodeKind::Image(_)))); + images + }; + assert_eq!(expanded_images(normal), 1); + assert_eq!(expanded_images(d), 0); +} + #[test] fn the_color_model_and_anti_alias_reach_the_pixels() { let mut s = session(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 4811b8ca1..d37b07685 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -552,6 +552,7 @@ pub fn load_file(bytes: &[u8]) -> Result { // Untrusted: capped, named and pointing at objects the file has. doc.tidy_saved_selections(); doc.tidy_graph_designs(); + doc.raster_effects.add_around = doc.raster_effects.add_around.clamp(0.0, vectorcraft_doc::rastersettings::MAX_ADD_AROUND); let mut depth_notes = vec![]; doc.limit_depth_noting(&mut depth_notes); let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))).collect(); diff --git a/docs/mcp.md b/docs/mcp.md index 621301a76..8ba2017d5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2344,8 +2344,9 @@ written unless the whole batch succeeds puts the saves and exports last, or runs `document.rasterEffectsSettings` holds how raster effects (shadows, glows, blurs, feathers) become images when PDF export or Expand Appearance renders them: `resolution` (ppi, also New Document's `rasterEffectsPpi`), `colorModel` (the document's `rgb`/`cmyk`, `grayscale` or `bitmap`), `background` (`white` makes the images opaque), `antiAlias` -(off: hard edges), `clippingMask` (the white stays under the art only), `addAround` (points of room around the art) -and `preserveSpotColors` (stored). They are also the defaults of `object.rasterize`, whose params override them (its +(off: hard edges), `clippingMask` (the white stays under the art only), `addAround` (0 to 1,000 points of room +around the art; a file's value outside that range opens as the nearer end) and `preserveSpotColors` (stored). They are +also the defaults of `object.rasterize`, whose params override them (its `clippingMask` puts the image in a clip group with the art's outline). No params reports them; any of them changes them in one undo step. PDF export renders the raster effects of every kind of object this way (paths, groups, layers, type, images, symbol instances, live objects, and those in opacity masks, symbols and pattern tiles): shadows and outer glows From 8ca4cd2cbf1b4e2420fe3ca783f0cc6f725238fd Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:42:09 +0000 Subject: [PATCH 015/208] Fix graph axis labels saturating beyond the i64 range Found while testing crates/engine/src/cmd/graph.rs:193 (fmt_value) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/graph.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 4e8086393..8ade093e8 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -192,7 +192,9 @@ fn drop_empty_rows(g: &mut GraphSpec) { fn fmt_value(v: f64) -> String { if (v - v.round()).abs() < 1e-9 { - format!("{}", v.round() as i64) + // Beyond the i64 range the cast would saturate; every f64 that large is a whole number, so + // `{:.0}` prints its exact digits. + if v.abs() < 9e18 { format!("{}", v.round() as i64) } else { format!("{v:.0}") } } else { format!("{:.2}", v).trim_end_matches('0').trim_end_matches('.').to_string() } @@ -1900,6 +1902,16 @@ mod tests { use crate::{NodeId, Session}; + #[test] + fn fmt_value_does_not_saturate_beyond_i64() { + assert_eq!(super::fmt_value(1e19), "10000000000000000000"); + assert_eq!(super::fmt_value(2e19), "20000000000000000000"); + assert_eq!(super::fmt_value(-2e19), "-20000000000000000000"); + assert_eq!(super::fmt_value(42.0), "42"); + assert_eq!(super::fmt_value(-0.0), "0"); + assert_eq!(super::fmt_value(2.5), "2.5"); + } + fn graph(s: &mut Session, ty: &str) -> NodeId { let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"; NodeId( From 3688ad7a46708c15469ac593a8aeae531b6a6e4d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:33:39 +0200 Subject: [PATCH 016/208] Reject '+' and other non-hex characters in Color::from_hex Found while testing crates/color/src/lib.rs:184 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/lib.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 742dfb749..6fc854ff0 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -181,6 +181,10 @@ impl Color { _ => {} } let h = s.strip_prefix('#').unwrap_or(s); + // `from_str_radix` accepts a leading `+`, so check the digits first. + if !h.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } let p = |i: usize, n: usize| u8::from_str_radix(h.get(i..i + n)?, 16).ok(); match h.len() { 3 => Some(Color::rgb8(p(0, 1)? * 17, p(1, 1)? * 17, p(2, 1)? * 17)), @@ -412,6 +416,14 @@ mod tests { assert_eq!(Color::from_hex("zz"), None); } + #[test] + fn hex_rejects_sign_characters() { + for bad in ["#+1+2+3", "+f+f+f", "#-1-2-3", "#+12+34+56", "#+f+f+f+f", "#12345+"] { + assert_eq!(Color::from_hex(bad), None, "{bad}"); + } + assert_eq!(Color::from_hex("#123"), Color::from_hex("#112233")); + } + #[test] fn hsb_roundtrip() { for hex in ["#ff0000", "#00ff00", "#0000ff", "#336699", "#ffffff", "#000000", "#c0ffee"] { From 80685831acbb867e655e07592c34ea400d4f9ca7 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:31:26 +0200 Subject: [PATCH 017/208] Escape XML ID first characters that cannot start an XML name Found while testing crates/doc/src/xml_id.rs:31 (from_object_name) / :10 (to_object_name) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/doc/src/xml_id.rs | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/crates/doc/src/xml_id.rs b/crates/doc/src/xml_id.rs index 4aa35f028..eced91be1 100644 --- a/crates/doc/src/xml_id.rs +++ b/crates/doc/src/xml_id.rs @@ -27,7 +27,8 @@ pub fn to_object_name(id: &str) -> String { out } -/// The XML id for an object name (the inverse of [`to_object_name`]). +/// The XML id for an object name (the inverse of [`to_object_name`]). The empty name stays empty, +/// since a bare `_` would read back as a space. pub fn from_object_name(name: &str) -> String { let mut out = String::with_capacity(name.len().saturating_mul(2)); for (i, c) in name.chars().enumerate() { @@ -35,17 +36,14 @@ pub fn from_object_name(name: &str) -> String { out.push('_'); } else if c == '_' { out.push_str("_x5F_"); - } else if i == 0 && c.is_ascii_digit() { + } else if (i == 0 && !(c.is_alphabetic() || c == ':')) || !(c.is_alphanumeric() || c == '.' || c == '-' || c == ':') { + // A first character that cannot start an XML name is escaped, never given a bare `_` + // prefix (that would read back as a space). out.push_str(&format!("_x{:X}_", u32::from(c))); - } else if c.is_alphanumeric() || c == '.' || c == '-' || c == ':' { - out.push(c); } else { - out.push_str(&format!("_x{:X}_", u32::from(c))); + out.push(c); } } - if out.is_empty() || !starts_name(&out) { - out.insert(0, '_'); - } out } @@ -66,10 +64,6 @@ pub fn is_valid(id: &str) -> bool { chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-' | ':')) } -fn starts_name(id: &str) -> bool { - id.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == ':') -} - /// `_xHHHH_` at the start of `s`: the character and the escape's length. fn escaped(s: &str) -> Option<(char, usize)> { let body = s.strip_prefix("_x")?; @@ -114,4 +108,15 @@ mod tests { assert!(is_valid("Layer_1") && is_valid("_x31_abc") && is_valid(":root")); assert!(!is_valid("My Rect") && !is_valid("1abc") && !is_valid("")); } + + #[test] + fn round_trip_names_that_cannot_start_an_xml_name() { + for name in ["-abc", ".5 mm", "²x", "Ⅷ", "", "1abc", "Layer 1", " lead", "_a", ":a", "#"] { + let id = from_object_name(name); + assert_eq!(to_object_name(&id), name, "name {name:?} -> id {id:?}"); + } + assert_eq!(from_object_name("-abc"), "_x2D_abc"); + assert_eq!(from_object_name(".5 mm"), "_x2E_5_mm"); + assert_eq!(from_object_name("²x"), "_xB2_x"); + } } From efdb04db8a41e1d244a9585ab0ef3589880d24a8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 08:14:21 -0700 Subject: [PATCH 018/208] Lint: action_batch.rs compiles without an unused import on wasm32 On main, cargo xtask wasm printed an unused-import warning for json in crates/engine/src/cmd/fileio/action_batch.rs, and this check reported it as an error: RUSTFLAGS=-Dwarnings cargo check -p vectorcraft-engine --target wasm32-unknown-unknown Every use of json! in action_batch.rs is in code compiled only for native targets. The wasm32 version of action_batch uses Value only. The json import now has the same #[cfg(not(target_arch = "wasm32"))] attribute as the code that uses it. Native builds import the same names as before. --- crates/engine/src/cmd/fileio/action_batch.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index ccc486896..f81957ff3 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -5,7 +5,9 @@ //! Save and Close, or Folder; Override Open / Save / Export. Cited: //! -use serde_json::{Value, json}; +use serde_json::Value; +#[cfg(not(target_arch = "wasm32"))] +use serde_json::json; use super::super::*; From 743930ac1188d162a86d90b96ea4933c9306cbb7 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:35:33 +0200 Subject: [PATCH 019/208] Wayland: give winit the desktop cursor theme so the pointer returns over panels (#2450) Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft/src/cursor_theme.rs | 121 ++++++++++++++++++ apps/vectorcraft/src/main.rs | 4 + .../ai.storyteller.vectorcraft.bundle.yml | 4 + .../flatpak/ai.storyteller.vectorcraft.yml | 4 + 4 files changed, 133 insertions(+) create mode 100644 apps/vectorcraft/src/cursor_theme.rs diff --git a/apps/vectorcraft/src/cursor_theme.rs b/apps/vectorcraft/src/cursor_theme.rs new file mode 100644 index 000000000..b23d1099d --- /dev/null +++ b/apps/vectorcraft/src/cursor_theme.rs @@ -0,0 +1,121 @@ +//! Linux Wayland: tell winit where the cursor theme is (#2450). +//! +//! Without the cursor-shape protocol (GNOME 46 and others) winit loads the pointer from an Xcursor +//! theme itself. When that lookup finds nothing, no pointer is ever set and the compositor keeps +//! showing its default one, until the canvas hides the system pointer to paint a tool glyph +//! (`CursorIcon::None`). Then the arrow never comes back over the panels and menus. The lookup +//! fails in a Flatpak sandbox (`XCURSOR_PATH` unset, no cursor theme in the runtime) and where the +//! `default` theme is a dangling symlink; the desktop's own setting names a theme that loads. +//! +//! This reads the desktop's cursor theme and size and sets what the user left unset, once, before +//! any thread starts and before the window opens. +#![cfg_attr(not(target_os = "linux"), allow(dead_code))] + +use std::ffi::OsString; + +/// Where a Flatpak sandbox mounts the host's cursor themes (user and system). +const FLATPAK_HOST_ICONS: [&str; 2] = ["/run/host/user-share/icons", "/run/host/share/icons"]; + +/// The desktop's cursor settings (GNOME's `org.gnome.desktop.interface`). +#[derive(Debug, Default, PartialEq)] +pub struct DesktopCursor { + pub theme: Option, + pub size: Option, +} + +/// The variables to set, given the environment, the host icon folders that exist and the +/// desktop's cursor settings. Empty unless this is a Wayland session; variables the user set +/// stay as they are. +pub fn plan(var: impl Fn(&str) -> Option, host_icons: &[&str], desktop: DesktopCursor) -> Vec<(&'static str, OsString)> { + let set = |name: &str| var(name).is_some_and(|v| !v.is_empty()); + if !(set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) { + return Vec::new(); + } + let mut out = Vec::new(); + if !set("XCURSOR_PATH") && !host_icons.is_empty() { + out.push(("XCURSOR_PATH", OsString::from(host_icons.join(":")))); + } + if !set("XCURSOR_THEME") + && let Some(theme) = desktop.theme.filter(|t| !t.is_empty()) + { + out.push(("XCURSOR_THEME", OsString::from(theme))); + } + if !set("XCURSOR_SIZE") + && let Some(size) = desktop.size.filter(|s| (1..=512).contains(s)) + { + out.push(("XCURSOR_SIZE", OsString::from(size.to_string()))); + } + out +} + +/// The value of a `gsettings get` line: `'Adwaita'` becomes `Adwaita`, `24` stays `24`. +fn gsettings_value(output: &str) -> &str { + output.trim().trim_matches('\'') +} + +#[cfg(target_os = "linux")] +fn desktop_cursor() -> DesktopCursor { + let get = |key: &str| { + let out = std::process::Command::new("gsettings").args(["get", "org.gnome.desktop.interface", key]).output().ok()?; + out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned()) + }; + DesktopCursor { + theme: get("cursor-theme").map(|v| gsettings_value(&v).to_owned()), + size: get("cursor-size").and_then(|v| gsettings_value(&v).parse().ok()), + } +} + +/// Sets `XCURSOR_PATH`, `XCURSOR_THEME` and `XCURSOR_SIZE` where a Wayland session leaves them +/// unset. Call first thing in `main`, while the process is single-threaded. +#[cfg(target_os = "linux")] +#[allow(unsafe_code)] +pub fn apply() { + let host: Vec<&str> = FLATPAK_HOST_ICONS.iter().copied().filter(|p| std::path::Path::new(p).is_dir()).collect(); + for (name, value) in plan(|n| std::env::var_os(n), &host, desktop_cursor()) { + log::debug!("cursor theme: {name}={value:?}"); + // SAFETY: called at the top of `main`, before the app starts any thread. + unsafe { std::env::set_var(name, value) }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |name| pairs.iter().find(|(k, _)| *k == name).map(|(_, v)| OsString::from(v)) + } + + fn adwaita() -> DesktopCursor { + DesktopCursor { theme: Some("Adwaita".into()), size: Some(24) } + } + + #[test] + fn a_wayland_session_gets_the_desktop_theme_and_the_host_icons() { + let got = plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &["/run/host/share/icons"], adwaita()); + assert_eq!( + got, + vec![ + ("XCURSOR_PATH", OsString::from("/run/host/share/icons")), + ("XCURSOR_THEME", OsString::from("Adwaita")), + ("XCURSOR_SIZE", OsString::from("24")), + ] + ); + } + + #[test] + fn settings_the_user_made_and_other_sessions_stay_untouched() { + let set = [("WAYLAND_DISPLAY", "wayland-0"), ("XCURSOR_PATH", "/x"), ("XCURSOR_THEME", "Bibata"), ("XCURSOR_SIZE", "32")]; + assert!(plan(env(&set), &["/run/host/share/icons"], adwaita()).is_empty()); + assert!(plan(env(&[("DISPLAY", ":0")]), &["/run/host/share/icons"], adwaita()).is_empty()); + // A theme-less desktop and an absurd size set nothing. + let odd = DesktopCursor { theme: Some(String::new()), size: Some(0) }; + assert!(plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &[], odd).is_empty()); + } + + #[test] + fn gsettings_output_is_unquoted() { + assert_eq!(gsettings_value("'Adwaita'\n"), "Adwaita"); + assert_eq!(gsettings_value("24\n"), "24"); + } +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 17bc010a3..c7a395a61 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -27,6 +27,7 @@ mod clipboard; #[cfg(feature = "wgpu")] mod compositor_gpu; mod control_server; +mod cursor_theme; #[cfg(feature = "wgpu")] mod gpu; mod logging; @@ -654,6 +655,9 @@ Without a window (converting, scripting, the MCP server), use vectorcraft-cli: "; fn main() -> std::process::ExitCode { + // Before any thread starts: the pointer must find a cursor theme on Wayland (#2450). + #[cfg(target_os = "linux")] + cursor_theme::apply(); // Validate roots before logging, font scans, preferences or the window: a missing value // must not clear an environment root and start the control channel without confinement. let mut control = automation_args::ControlArg::default(); diff --git a/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml index b245e0b31..687445a55 100644 --- a/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml @@ -24,6 +24,10 @@ finish-args: - --socket=fallback-x11 # GPU access for the wgpu renderer (Vulkan, or GL as a fallback). - --device=dri + # Cursor theme: without the cursor-shape protocol (e.g. GNOME 46) winit loads the theme itself. + # The sandbox sets no XCURSOR_PATH and ships no cursor themes, so the pointer would turn invisible + # over the window. The host's themes are mounted under /run/host (#2450). + - --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons # Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document # portal, so broad filesystem access isn't needed. Pictures is granted for opening files by # path from the command line. diff --git a/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml b/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml index 865609509..cbee5b720 100644 --- a/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml @@ -22,6 +22,10 @@ finish-args: - --socket=wayland - --socket=fallback-x11 - --device=dri + # Cursor theme: without the cursor-shape protocol (e.g. GNOME 46) winit loads the theme itself. + # The sandbox sets no XCURSOR_PATH and ships no cursor themes, so the pointer would turn invisible + # over the window. The host's themes are mounted under /run/host (#2450). + - --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons # Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document # portal, so broad filesystem access isn't needed. Pictures is granted for opening files by # path from the command line. From 37dfdd5da5fd111ff9663a09ac0a21d7ad9c8b07 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:38:48 +0200 Subject: [PATCH 020/208] Cursor: keep the system pointer where no cursor theme loads on Wayland (#2450) Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft/src/cursor_theme.rs | 86 ++++++++++++++++++++++++++++ apps/vectorcraft/src/main.rs | 1 + crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/cursors.rs | 23 ++++++++ crates/ui-egui/src/lib.rs | 4 ++ 5 files changed, 116 insertions(+), 1 deletion(-) diff --git a/apps/vectorcraft/src/cursor_theme.rs b/apps/vectorcraft/src/cursor_theme.rs index b23d1099d..b0a6730df 100644 --- a/apps/vectorcraft/src/cursor_theme.rs +++ b/apps/vectorcraft/src/cursor_theme.rs @@ -7,11 +7,15 @@ //! fails in a Flatpak sandbox (`XCURSOR_PATH` unset, no cursor theme in the runtime) and where the //! `default` theme is a dangling symlink; the desktop's own setting names a theme that loads. //! +//! Where no cursor theme can be found at all (KDE or sway without gsettings), the env fix does +//! nothing: [`theme_missing`] tells the canvas to keep the system pointer visible beside the tool glyph. +//! //! This reads the desktop's cursor theme and size and sets what the user left unset, once, before //! any thread starts and before the window opens. #![cfg_attr(not(target_os = "linux"), allow(dead_code))] use std::ffi::OsString; +use std::path::{Path, PathBuf}; /// Where a Flatpak sandbox mounts the host's cursor themes (user and system). const FLATPAK_HOST_ICONS: [&str; 2] = ["/run/host/user-share/icons", "/run/host/share/icons"]; @@ -48,6 +52,67 @@ pub fn plan(var: impl Fn(&str) -> Option, host_icons: &[&str], desktop out } +/// Xcursor's default search path when `XCURSOR_PATH` is unset (relative entries are in `$HOME`). +const DEFAULT_ICON_DIRS: [&str; 5] = ["~/.local/share/icons", "~/.icons", "/usr/share/icons", "/usr/share/pixmaps", "/usr/X11R6/lib/X11/icons"]; + +/// The themes `index.theme` lists after `Inherits=`. +fn inherits(index: &str) -> Vec { + index + .lines() + .find_map(|l| l.trim().strip_prefix("Inherits=")) + .map(|v| v.split([',', ';']).map(|t| t.trim().to_owned()).filter(|t| !t.is_empty()).collect()) + .unwrap_or_default() +} + +/// Can Xcursor load a pointer for the environment's theme (`XCURSOR_THEME`, else `default`)? It does +/// when the theme, or one it inherits from, has a `default` or `left_ptr` cursor file in a folder of +/// `XCURSOR_PATH` (else Xcursor's default folders). `read` returns a file's text, `exists` tells +/// whether a file is there. +pub fn theme_usable( + var: impl Fn(&str) -> Option, + home: Option<&Path>, + read: impl Fn(&Path) -> Option, + exists: impl Fn(&Path) -> bool, +) -> bool { + let dirs: Vec = match var("XCURSOR_PATH").filter(|v| !v.is_empty()) { + Some(v) => std::env::split_paths(&v).collect(), + None => DEFAULT_ICON_DIRS + .iter() + .filter_map(|d| match d.strip_prefix("~/") { + Some(rest) => home.map(|h| h.join(rest)), + None => Some(PathBuf::from(d)), + }) + .collect(), + }; + let first = var("XCURSOR_THEME").and_then(|v| v.into_string().ok()).filter(|t| !t.is_empty()).unwrap_or_else(|| "default".into()); + let mut todo = vec![first]; + let mut seen: Vec = Vec::new(); + while let Some(theme) = todo.pop() { + // Bounded: themes inherit in short chains, and a cycle must end. + if seen.contains(&theme) || seen.len() >= 16 { + continue; + } + for dir in &dirs { + let base = dir.join(&theme); + if ["default", "left_ptr"].iter().any(|c| exists(&base.join("cursors").join(c))) { + return true; + } + if let Some(index) = read(&base.join("index.theme")) { + todo.extend(inherits(&index)); + } + } + seen.push(theme); + } + false +} + +/// On this machine, after [`apply`]: is there no cursor theme Xcursor can load? Then winit never +/// sets a pointer, and the canvas must not hide the system one (#2450). +pub fn theme_missing() -> bool { + let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(PathBuf::from); + !theme_usable(|n| std::env::var_os(n), home.as_deref(), |p| std::fs::read_to_string(p).ok(), |p| p.exists()) +} + /// The value of a `gsettings get` line: `'Adwaita'` becomes `Adwaita`, `24` stays `24`. fn gsettings_value(output: &str) -> &str { output.trim().trim_matches('\'') @@ -113,6 +178,27 @@ mod tests { assert!(plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &[], odd).is_empty()); } + #[test] + fn a_theme_is_usable_directly_or_through_inherits_and_missing_otherwise() { + let files = ["/i/Bibata/cursors/left_ptr", "/i/Breeze/index.theme", "/i/Loop/index.theme"]; + let exists = |p: &Path| p.to_str().is_some_and(|p| files.contains(&p)); + let read = |p: &Path| match p.to_str()? { + "/i/Breeze/index.theme" => Some("[Icon Theme]\nInherits=Other, Bibata\n".to_owned()), + "/i/Loop/index.theme" => Some("Inherits=Loop\n".to_owned()), + _ => None, + }; + let with = |theme: &'static str| { + move |n: &str| (n == "XCURSOR_PATH").then(|| OsString::from("/i")).or((n == "XCURSOR_THEME").then(|| OsString::from(theme))) + }; + assert!(theme_usable(with("Bibata"), None, read, exists)); + assert!(theme_usable(with("Breeze"), None, read, exists)); + // No such theme, a theme without cursors, an inheritance loop: nothing to load. + assert!(!theme_usable(with("Nope"), None, read, exists)); + assert!(!theme_usable(with("Loop"), None, read, exists)); + // Unset: Xcursor's default folders and the `default` theme. + assert!(!theme_usable(|_| None, Some(Path::new("/home/u")), read, exists)); + } + #[test] fn gsettings_output_is_unquoted() { assert_eq!(gsettings_value("'Adwaita'\n"), "Adwaita"); diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index c7a395a61..23aaea179 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -526,6 +526,7 @@ fn services(parent: Parent, ui: egui::Context) -> Services { start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick), dialogs_are_sheets: cfg!(target_os = "macos"), wayland, + no_cursor_theme: wayland && cursor_theme::theme_missing(), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(write_file)), // Background Save and Export write from a worker thread. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e6b6157f8..2870d1d51 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -565,7 +565,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { && crate::cursors::paint_glyphs(crate::cursors::HIDES_ONLY_WHEN_KEY, ui.input(|i| i.focused)) && crate::cursors::paint(&painter, c, hp, 1.0 / ui_scale) => { - egui::CursorIcon::None + // No cursor theme (Wayland): the glyph is painted, the system pointer stays (#2450). + if crate::cursors::hide_system_pointer(app.services.no_cursor_theme) { egui::CursorIcon::None } else { cursor_icon(c) } } _ => cursor_icon(c), } diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index bdf2d127c..4f6723118 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -581,6 +581,13 @@ pub fn os_cursors(build: bool, wayland: bool) -> bool { build && !wayland } +/// Does the canvas hide the system pointer to show a painted tool glyph alone? Not when there is no +/// cursor theme on Wayland ([`crate::Services::no_cursor_theme`]): winit sets no pointer then, and a +/// hidden one would not come back over the panels (#2450). The system pointer stays beside the glyph. +pub fn hide_system_pointer(no_cursor_theme: bool) -> bool { + !no_cursor_theme +} + /// Does the window hide the pointer only while it is the key window? macOS does: winit hides it with /// a cursor rect holding a transparent cursor, and macOS applies the rect only in the key window. /// Over a window behind another app or under a sheet, the system pointer shows (#2020). @@ -927,6 +934,22 @@ mod tests { assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::None); } + /// Without a cursor theme the canvas keeps the system pointer visible (#2450). + #[test] + fn no_cursor_theme_keeps_the_system_pointer() { + assert!(!hide_system_pointer(true)); + assert!(hide_system_pointer(false)); + let mut app = VectorcraftApp::new(Session::new(), crate::Services { wayland: true, no_cursor_theme: true, ..Default::default() }); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + for _ in 0..3 { + frame(&mut app, &ctx, vec![]); + } + let over = app.canvas_rect.unwrap().center(); + let out = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]); + assert_ne!(out.platform_output.cursor_icon, egui::CursorIcon::None); + } + /// On macOS a window without the focus shows the system pointer over the canvas, and the glyph /// is not painted next to it (#2020). With the focus back, the canvas sets `CursorIcon::None` again /// on the next frame, without a pointer move. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bc1fe3d8a..64998ed24 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -275,6 +275,10 @@ pub struct Services { /// The window is on Wayland: the tool cursors are painted into it instead of handed to the /// compositor as bitmaps ([`cursors::os_cursors`]). pub wayland: bool, + /// On Wayland, no cursor theme can be loaded, so winit sets no pointer and the compositor's + /// default one would never come back once the canvas hid it: the canvas keeps the system + /// pointer visible next to the tool glyph ([`cursors::hide_system_pointer`], #2450). + pub no_cursor_theme: bool, } /// Cached canvas raster. From 6961bed99debd5a3fe69c4f9f3c05dd555c4bd5f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 19:51:46 -0700 Subject: [PATCH 021/208] Tests: CLI tests do not fail on folders an earlier run left behind The tmp() helpers in apps/vectorcraft-cli/tests/cli.rs and fileio.rs name their folder after the process id (vectorcraft-cli-test-, vectorcraft-cli-fileio- in the temp folder) and never empty or remove it. When a later run gets the same process id, it finds the earlier run's files there. mcp_automation_roots then fails on Unix with EEXIST (Os { code: 17, kind: AlreadyExists, message: "File exists" }): its first symlink() call finds the link in/escape.svg that the earlier run made. A stale output file can also let a fileio test read an earlier run's export. The first tmp() call in each test process now removes the folder and creates it again empty. The roots test's body moves into automation_roots(), and mcp_automation_roots_in_a_folder_an_earlier_run_left runs it twice in one folder, emptying the folder in between as tmp() does. Without the removal the second run fails with the same error. --- apps/vectorcraft-cli/tests/cli.rs | 27 ++++++++++++++++++++++++--- apps/vectorcraft-cli/tests/fileio.rs | 11 +++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/vectorcraft-cli/tests/cli.rs b/apps/vectorcraft-cli/tests/cli.rs index 37bb28c22..133c51df1 100644 --- a/apps/vectorcraft-cli/tests/cli.rs +++ b/apps/vectorcraft-cli/tests/cli.rs @@ -7,10 +7,18 @@ use serde_json::{Value, json}; const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli"); +/// Returns the path of `name` in `vectorcraft-cli-test-` in the temp folder. The first call removes +/// that folder and creates it again empty. fn tmp(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id())); + static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); + DIR.get_or_init(|| empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id())))).join(name) +} + +/// Removes `dir` and everything in it, creates it again empty and returns it. +fn empty_dir(dir: std::path::PathBuf) -> std::path::PathBuf { + let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir.join(name) + dir } #[test] @@ -87,7 +95,20 @@ fn mcp_headless_over_stdio() { /// server starts, and the process then reads and writes only inside its roots. #[test] fn mcp_automation_roots() { - let base = tmp("roots"); + automation_roots(&tmp("roots")); +} + +/// A run that has the process id of an earlier run finds that run's files and links in its folder. +/// `empty_dir`, which `tmp` uses, removes them, so the run can make them again instead of failing +/// with "File exists". +#[test] +fn mcp_automation_roots_in_a_folder_an_earlier_run_left() { + let dir = tmp("earlier-run"); + automation_roots(&dir.join("roots")); + automation_roots(&empty_dir(dir).join("roots")); +} + +fn automation_roots(base: &std::path::Path) { for d in ["in", "out", "outside"] { std::fs::create_dir_all(base.join(d)).unwrap(); } diff --git a/apps/vectorcraft-cli/tests/fileio.rs b/apps/vectorcraft-cli/tests/fileio.rs index db1174188..19e2b7b72 100644 --- a/apps/vectorcraft-cli/tests/fileio.rs +++ b/apps/vectorcraft-cli/tests/fileio.rs @@ -8,9 +8,16 @@ use serde_json::Value; const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli"); +/// Returns the path of `name` in `vectorcraft-cli-fileio-` in the temp folder. The first call +/// removes that folder and creates it again empty. fn tmp(name: &str) -> String { - let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); + let dir = DIR.get_or_init(|| { + let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + }); dir.join(name).to_string_lossy().to_string() } From 476d22826228ce419103fe03b04764129f284cbd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 16:09:12 -0700 Subject: [PATCH 022/208] Tests: a language one ui-egui test sets no longer translates the tests running beside it The language the UI is drawn in was one process-wide value in unit tests too, and the tests run on parallel threads. current_language_is_a_frame_setting sets Traditional Chinese for a moment, and a test that drew text in that moment read it in Chinese. chrome::tests::the_zoom_hint_follows_animated_zoom failed that way in 29 of 500 runs together with the i18n tests. The drawing language is now one per process in the app and one per thread in unit tests, as ui_mirror! keeps the shortcut and workspace mirrors. The app keeps the same static. A new test sets the language on another thread and checks that its own thread still draws in English. --- crates/ui-egui/src/i18n/mod.rs | 22 ++++++++++++++++++---- crates/ui-egui/src/i18n/tests.rs | 12 ++++++++++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index e2d8139d6..9698fb82a 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -457,19 +457,33 @@ fn detect_system_lang() -> Lang { Lang::EN } -/// Index into [`LANGUAGES`] of the language the UI is drawn in this frame. -static CURRENT: AtomicUsize = AtomicUsize::new(0); +/// Index into [`LANGUAGES`] of the language the UI is drawn in this frame. The app keeps one +/// index for the whole process. Unit tests keep one per thread, as `ui_mirror!` keeps the +/// shortcut and workspace mirrors, so a language one test sets does not change the language of +/// the tests running beside it on other threads. +fn current_index() -> &'static AtomicUsize { + #[cfg(not(test))] + { + static CURRENT: AtomicUsize = AtomicUsize::new(0); + &CURRENT + } + #[cfg(test)] + { + thread_local!(static CURRENT: &'static AtomicUsize = Box::leak(Box::default())); + CURRENT.with(|c| *c) + } +} /// Set the UI language for drawing (the shell calls this once per frame from the preference), so /// widgets can translate without every call site carrying a language around. pub fn set_current(lang: Lang) { let i = LANGUAGES.iter().position(|l| l.code == lang.code()).unwrap_or(0); - CURRENT.store(i, Ordering::Relaxed); + current_index().store(i, Ordering::Relaxed); } /// The language the UI is drawn in. pub fn current() -> Lang { - Lang(LANGUAGES.get(CURRENT.load(Ordering::Relaxed)).unwrap_or(&LANGUAGES[0])) + Lang(LANGUAGES.get(current_index().load(Ordering::Relaxed)).unwrap_or(&LANGUAGES[0])) } /// Does `lang` have a catalog entry for this plain string? (English never does: it is the source.) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index f5afab545..3ab33db42 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -100,6 +100,15 @@ fn current_language_is_a_frame_setting() { assert_eq!(t("Layers"), "Layers"); } +/// Unit tests run on parallel threads, and each one draws in its own language: a language set on +/// another thread (by a test running beside this one) leaves this thread's text in English. +#[test] +fn a_language_set_by_another_test_leaves_this_one_in_english() { + std::thread::spawn(|| set_current(ZH())).join().unwrap(); + assert_eq!(current(), Lang::EN); + assert_eq!(t("Layers"), "Layers"); +} + #[test] fn lookups_fall_back_to_english() { assert_eq!(tr(Lang::EN, "Layers"), "Layers"); @@ -197,8 +206,7 @@ fn partial_catalogs_only_translate_strings_the_ui_shows() { } /// The engine learns the language the UI is drawn in each frame: new type takes the Japanese -/// defaults while it is Japanese (#432). The frames stay in English: the drawing language is -/// process-wide, and a Japanese frame would translate the tests running beside this one. +/// defaults while it is Japanese (#432). The frames stay in English. #[test] fn the_engine_follows_the_interface_language() { let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); From 1ff7ac75574efe6fa6b0b1064d7ee97b80329351 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:27:30 +0200 Subject: [PATCH 023/208] Layers: Merge Selected with a layer and its sublayer highlighted merges into the last top-level layer (#2383) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/layerpanel.rs | 15 ++++++++++++-- crates/engine/src/tests_layers.rs | 32 ++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index f82e2890a..c302a7409 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -271,8 +271,19 @@ fn merge(s: &mut Session, p: &Value) -> Result { if ids.len() < 2 { return Err(bad(C, "highlight two or more rows to merge")); } - let into = - ids.iter().rev().copied().find(|id| d.node(*id).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "highlight a layer to merge into"))?; + // The last highlighted layer that is not inside another highlighted row: with a layer and its + // sublayer both highlighted (Select All in the panel), the sublayer travels with its layer + // instead of becoming the target (#2383). + let roots: HashSet = row_roots(d, &ids).into_iter().collect(); + let is_layer = |id: &NodeId| d.node(*id).is_some_and(Node::is_layer); + let into = ids + .iter() + .rev() + .copied() + .filter(is_layer) + .find(|id| roots.contains(id)) + .or_else(|| ids.iter().rev().copied().find(is_layer)) + .ok_or_else(|| bad(C, "highlight a layer to merge into"))?; let around = d.ancestry(into).unwrap_or_default(); // (Rows inside the target layer merge too: only rows inside other listed rows go with those.) let rest: Vec = ids.iter().copied().filter(|id| *id != into).collect(); diff --git a/crates/engine/src/tests_layers.rs b/crates/engine/src/tests_layers.rs index 1e2e7e221..1fc789c80 100644 --- a/crates/engine/src/tests_layers.rs +++ b/crates/engine/src/tests_layers.rs @@ -578,7 +578,11 @@ fn collect_merge_flatten_and_reverse() { run(&mut s, "layer.merge", json!({"ids": [col.0, layer.0]})); assert!(s.doc().unwrap().doc.node(col).is_none()); assert_eq!(children(&s, layer), vec![c, sub, a, g]); - assert!(s.execute("layer.merge", &json!({"ids": [layer.0, sub.0]})).is_err(), "not into its own sublayer"); + // A layer and its sublayer: the sublayer merges into its layer, never the other way round. + run(&mut s, "layer.merge", json!({"ids": [layer.0, sub.0]})); + assert!(s.doc().unwrap().doc.node(sub).is_none()); + assert_eq!(children(&s, layer), vec![c, a, g]); + run(&mut s, "edit.undo", json!({})); assert!(s.execute("layer.merge", &json!({"ids": [layer.0]})).is_err()); // Flatten: other visible layers merge in, hidden ones go, templates stay. let l2 = id(&run(&mut s, "layer.new", json!({"top": true}))); @@ -595,6 +599,32 @@ fn collect_merge_flatten_and_reverse() { assert_eq!(undos(&s), before + 1); } +/// Merge Selected with every row highlighted (a layer, its sublayer and another layer, in panel +/// order) merges the layers into the last top-level one instead of refusing a sublayer target (#2383). +#[test] +fn merge_selected_with_all_layer_rows_highlighted_merges_into_the_last_top_level_layer() { + for reversed in [false, true] { + let mut s = session(); + let (layer, sub, a, _, _, c) = tree(&mut s); + let l2 = id(&run(&mut s, "layer.new", json!({"top": true}))); + let d = rect(&mut s, 500.0); + // Panel order, top first: Layer 2, Layer 1, its sublayer. + let mut rows = vec![l2.0, layer.0, sub.0]; + if reversed { + rows.reverse(); + } + run(&mut s, "layer.highlight", json!({"ids": rows})); + let into = id(&run(&mut s, "layer.merge", json!({}))); + let st = s.doc().unwrap(); + assert_eq!(st.doc.layers.len(), 1, "reversed: {reversed}"); + assert_eq!(into, st.doc.layers[0].id); + for n in [a, c, d] { + assert!(st.doc.layer_of(n) == Some(into), "{n:?} reversed: {reversed}"); + } + assert!(st.doc.node(sub).is_some() || st.doc.node(layer).is_some() || st.doc.node(l2).is_some()); + } +} + /// Collect in New Layer puts the new layer beside the topmost row when that row is directly in a /// layer, and on top of the layer holding its group when it is in a group. When that row is in a /// hidden or locked layer, the selected art it collects from another layer is deselected, the key From 7e423f91e934eb63873f5f0d2e1a3c9b7d95df15 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 21:08:36 +0200 Subject: [PATCH 024/208] Overprint Preview: overprinting draws with Darken, so a colour over itself stays that colour (#2415) Multiply squared a colour overprinting itself (stroke over same-colour fill showed a darker band). Darken keeps the stronger ink per plate/channel. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/overprint.rs | 17 +++++++++-------- crates/pdf/src/print/mod.rs | 8 ++++---- crates/pdf/src/print/plates.rs | 6 +++--- crates/pdf/src/tests_printadvanced.rs | 4 ++-- crates/render/src/proof.rs | 8 ++++---- crates/render/src/tests_cmykblend.rs | 19 +++++++++++++++++++ docs/mcp.md | 4 ++-- 7 files changed, 43 insertions(+), 23 deletions(-) diff --git a/crates/doc/src/overprint.rs b/crates/doc/src/overprint.rs index 50b54d3bc..a822728a3 100644 --- a/crates/doc/src/overprint.rs +++ b/crates/doc/src/overprint.rs @@ -94,26 +94,27 @@ impl Node { } } -/// Draw the overprinting fills and strokes of `a`'s subtree with Multiply (those with a blend +/// Draw the overprinting fills and strokes of `a`'s subtree with Darken (those with a blend /// mode of their own keep it), copying only the nodes on the way to them: on screen and on a -/// printed plate this approximates their inks printing over the inks below. With `discard_white` +/// printed plate this approximates their inks printing over the inks below. Darken keeps the +/// stronger ink of each plate (and each additive channel), so a colour overprinting itself stays +/// that colour, where Multiply would darken it. With `discard_white` /// (Document Setup → Discard White Overprint) white fills and strokes knock out instead. -pub fn multiply_overprints(a: &mut Arc, discard_white: bool) { +pub fn darken_overprints(a: &mut Arc, discard_white: bool) { if !a.has_overprint() { return; } let n = Arc::make_mut(a); - let multiplies = - |overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint)); + let darkens = |overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint)); for it in &mut n.appearance.items { match it { - crate::AppearanceItem::Fill(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply, - crate::AppearanceItem::Stroke(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply, + crate::AppearanceItem::Fill(l) if darkens(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Darken, + crate::AppearanceItem::Stroke(l) if darkens(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Darken, _ => {} } } for c in n.children_mut().into_iter().flatten() { - multiply_overprints(c, discard_white); + darken_overprints(c, discard_white); } } diff --git a/crates/pdf/src/print/mod.rs b/crates/pdf/src/print/mod.rs index 440231032..34762a2e0 100644 --- a/crates/pdf/src/print/mod.rs +++ b/crates/pdf/src/print/mod.rs @@ -36,7 +36,7 @@ use kurbo::{Affine, Rect}; use serde::Serialize; use vectorcraft_color::cms::{self, ProfileKind}; use vectorcraft_doc::marks::PrinterMarks; -use vectorcraft_doc::overprint::{clear_overprints, multiply_overprints}; +use vectorcraft_doc::overprint::{clear_overprints, darken_overprints}; use vectorcraft_doc::{ColorMode, Document, Node, NodeKind}; pub use layout::{MAX_TILES, TileGrid}; @@ -167,7 +167,7 @@ pub fn printed_document<'a>(doc: &'a Document, set: &PrintSettings) -> Cow<'a, D } /// Overprinting fills and strokes in composite output: discarded (they knock out) or simulated -/// (drawn with Multiply, as Overprint Preview shows them), after which nothing overprints any +/// (drawn with Darken, as Overprint Preview shows them), after which nothing overprints any /// more; or preserved. Separations always honour them. fn composite_overprints(doc: &mut Document, set: &PrintSettings) { let how = set.advanced.overprints; @@ -179,7 +179,7 @@ fn composite_overprints(doc: &mut Document, set: &PrintSettings) { let nodes = doc.layers.iter_mut().chain(symbols).chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())); for n in nodes { if how == PrintOverprints::Simulate { - multiply_overprints(n, discard_white); + darken_overprints(n, discard_white); } clear_overprints(n); } @@ -411,7 +411,7 @@ fn print_inner(doc: &Document, opts: &PrintOptions) -> Result Separator<'a> { } /// The document as plate `plate`: art, symbols, pattern tiles and images in the grey of the - /// ink's coverage. Overprinting fills and strokes multiply, so where they have none of this + /// ink's coverage. Overprinting fills and strokes darken, so where they have none of this /// ink the plate below shows through instead of being knocked out. pub fn plate(&self, plate: &str) -> Document { let mut d = self.doc.clone(); let discard_white = d.setup.discard_white_overprint; let nodes = d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.patterns.iter_mut().flat_map(|p| p.art.iter_mut())); for n in nodes { - multiply_overprints(n, discard_white); + darken_overprints(n, discard_white); self.node(plate, Arc::make_mut(n)); } for blob in d.images.values_mut() { diff --git a/crates/pdf/src/tests_printadvanced.rs b/crates/pdf/src/tests_printadvanced.rs index ca0abc07c..d1ae7c2e3 100644 --- a/crates/pdf/src/tests_printadvanced.rs +++ b/crates/pdf/src/tests_printadvanced.rs @@ -98,13 +98,13 @@ fn discarded_and_simulated_overprints_write_no_overprint() { assert!(preserve.warnings.is_empty(), "{:?}", preserve.warnings); assert!(String::from_utf8_lossy(&preserve.bytes).contains("/OP true/op true/OPM 1"), "preserved overprints overprint in the file"); assert!(overprints(&printed_document(&d, &settings(json!({})))), "preserved"); - for (how, multiplies) in [("discard", false), ("simulate", true)] { + for (how, darkens) in [("discard", false), ("simulate", true)] { let set = json!({"advanced": {"overprints": how}}); assert!(!overprints(&printed_document(&d, &settings(set.clone()))), "{how}: nothing overprints"); let r = job(&d, set); let text = String::from_utf8_lossy(&r.bytes); assert!(!text.contains("/OP"), "{how}: no overprint in the file"); - assert_eq!(text.contains("/Multiply"), multiplies, "{how}"); + assert_eq!(text.contains("/Darken"), darkens, "{how}"); assert!(!r.warnings.iter().any(|w| w.contains("knockouts")), "{how}: {:?}", r.warnings); } // Separations honour overprints whatever the option says. diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs index 10db44cb6..86c0319ed 100644 --- a/crates/render/src/proof.rs +++ b/crates/render/src/proof.rs @@ -11,7 +11,7 @@ //! Placed raster images are not separated (limitation). //! * **Overprint Preview** (and Separations Preview, which implies it): fills and strokes that //! overprint ([`vectorcraft_doc::FillLayer::overprint`], characters' too) are drawn with -//! Multiply, which approximates their inks printing over the inks below: a zero ink lets the +//! Darken, which approximates their inks printing over the inks below: a zero ink lets the //! inks below show through, where a knockout would replace them. //! //! The app's current view state lives in [`view`] / [`set_view`]; the canvas copies it into @@ -25,7 +25,7 @@ use vectorcraft_color::cms::{self, Cms, PROCESS_PLATES}; pub use vectorcraft_color::cms::{Intent, ProofSetup, ProofTarget}; use vectorcraft_color::{Color, Swatch, SwatchGroup}; pub use vectorcraft_doc::inks::{Inks, Link, Plate, inks, map_document_colors, map_node_colors, plates, spot_color}; -use vectorcraft_doc::overprint::multiply_overprints; +use vectorcraft_doc::overprint::darken_overprints; use vectorcraft_doc::{Document, Node, NodeId, Shared}; use crate::RenderOptions; @@ -104,7 +104,7 @@ impl PartialEq for Plates { } /// What [`prepare`] does to the fills and strokes of each node: overprinting ones multiply -/// ([`multiply_overprints`]), and with Separations Preview every color shows as the visible plates +/// ([`darken_overprints`]), and with Separations Preview every color shows as the visible plates /// print it ([`plate_color`]). struct How<'a> { doc: &'a Document, @@ -122,7 +122,7 @@ impl How<'_> { /// Change the fills, strokes and other colors of node `a`, which holds no members. fn apply(&self, a: &mut Arc) { - multiply_overprints(a, self.discard_white); + darken_overprints(a, self.discard_white); if let Some((c, proof, visible)) = self.plates { map_node_colors(Arc::make_mut(a), &mut |col, sw| plate_color(self.doc, c, proof, visible, col, sw)); } diff --git a/crates/render/src/tests_cmykblend.rs b/crates/render/src/tests_cmykblend.rs index 3ab3b19e8..9d3f1d933 100644 --- a/crates/render/src/tests_cmykblend.rs +++ b/crates/render/src/tests_cmykblend.rs @@ -261,3 +261,22 @@ fn rich_black_shows_black_ink_alone_as_rgb_black() { add(&mut d, n); assert_eq!(centre_with(&d, true), centre_with(&d, false), "an image shows as it is"); } + +/// An overprinting stroke in the colour of the fill under it shows no darker band in Overprint +/// Preview: the same ink printed over itself is that ink (#2415). Multiply would square the colour. +#[test] +fn overprint_of_the_same_colour_shows_no_darker_band() { + for (mode, c) in [(ColorMode::Rgb, Color::rgb(0.0, 0.55, 0.29)), (ColorMode::Cmyk, Color::cmyk(0.85, 0.0, 1.0, 0.0))] { + let mut d = Document::new_with_mode(40.0, 40.0, mode); + let mut n = + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), Appearance::basic(Paint::solid(c), Paint::solid(c), 8.0)); + for it in &mut n.appearance.items { + *it.overprint_mut() = true; + } + add(&mut d, n); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), overprint_preview: true, ..Default::default() }; + let r = Renderer::new().render_region_with(&d, d.artboards[0].rect, 1.0, &opts); + // (20, 20) is fill only; (12, 20) lies in the stroke's inner half, over the fill. + assert!(near(r.pixel(12, 20), r.pixel(20, 20), 2), "{mode:?}: {:?} vs {:?}", r.pixel(12, 20), r.pixel(20, 20)); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 8ba2017d5..bed84a08a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1107,7 +1107,7 @@ Each fill and stroke (and each character's fill and stroke) can overprint: its i of knocking them out. `object.setOverprint {fill?, stroke?, item?, ids?}` sets it (groups set their contents, type its characters too; `item` aims at one appearance item) and `attributes.info {ids?}` reads it back (`null` where the objects differ). Overprint Preview (`view.overprintPreview`) and Separations Preview show it; rendering approximates -it by multiplying. Older files that listed Overprint Black objects in the document get the flags on load. +it by darkening (the stronger ink of each plate wins, so a colour overprinting itself stays that colour). Older files that listed Overprint Black objects in the document get the flags on load. PDF export writes them with `advanced.overprint: "preserve"` (the default): each overprinting fill and stroke sets a graphics state with `/OP true /op true /OPM 1` (`/OPM 0` in PDF/A). With `discardWhiteOverprint` (`document.setup`, on by default) white ones knock out instead; `"discard"` writes none. @@ -3049,7 +3049,7 @@ The Print dialog's Advanced section and the printer profile are print settings t `file.print`): `advanced: {printAsBitmap, overprints: preserve|discard|simulate, flattenerPreset}` and `color: {profile}`. In composite output, `preserve` writes overprinting fills and strokes with an overprinting graphics state (`/OP`, as PDF export does), `discard` makes overprinting fills and strokes knock out and `simulate` prints them -as Overprint Preview shows them (multiplied); either way the file has no overprint left. Separations always keep +as Overprint Preview shows them (darkened); either way the file has no overprint left. Separations always keep overprints. `flattenerPreset` (High Resolution, Medium Resolution, Low Resolution or a saved preset, `flattener.presets.list`) flattens transparency before printing, as Object → Flatten Transparency does; empty, a PDF keeps it live (PostScript, which has none, uses `file.print`'s `flattenerPreset`, else this one, else medium). From fdc08e25d6aa2c5341c56a26b6b432765a359d6b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:33:08 +0200 Subject: [PATCH 025/208] Layers: Merge Selected merges the layers of art selected on the canvas (#2383) With no highlighted rows and art selected in two or more layers (Select All), layer.merge merges those layers; the panel menu enables it then. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/layerpanel.rs | 13 +++++++++++-- crates/engine/src/tests_layers.rs | 18 ++++++++++++++++++ crates/ui-egui/src/panels/layers.rs | 4 +++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index c302a7409..66cf37ff7 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -263,13 +263,22 @@ fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> { fn merge(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.merge"; - let ids = rows_param(s, p, C)?; + let mut ids = rows_param(s, p, C)?; let st = s.doc()?; let d = &st.doc; + // Nothing named or highlighted, but art selected on the canvas in several layers (Select All): + // merge those layers (#2383). + if p.get("ids").is_none() && p.get("id").is_none() && st.highlighted_rows().is_empty() { + let held: HashSet = st.selection.objects.iter().filter_map(|o| d.layer_of(*o)).collect(); + let layers: Vec = d.layers.iter().map(|l| l.id).filter(|l| held.contains(l)).collect(); + if layers.len() >= 2 { + ids = layers; + } + } let mut seen = HashSet::new(); let ids: Vec = ids.into_iter().filter(|id| d.node(*id).is_some() && seen.insert(*id)).collect(); if ids.len() < 2 { - return Err(bad(C, "highlight two or more rows to merge")); + return Err(bad(C, "highlight two or more rows to merge, or select art in two or more layers")); } // The last highlighted layer that is not inside another highlighted row: with a layer and its // sublayer both highlighted (Select All in the panel), the sublayer travels with its layer diff --git a/crates/engine/src/tests_layers.rs b/crates/engine/src/tests_layers.rs index 1fc789c80..7591873d2 100644 --- a/crates/engine/src/tests_layers.rs +++ b/crates/engine/src/tests_layers.rs @@ -625,6 +625,24 @@ fn merge_selected_with_all_layer_rows_highlighted_merges_into_the_last_top_level } } +/// Merge Selected with no highlighted rows but art selected in several layers (Select All on the +/// canvas) merges those layers; art in one layer still asks for rows (#2383). +#[test] +fn merge_selected_with_art_selected_in_several_layers_merges_those_layers() { + let mut s = session(); + let a = rect(&mut s, 0.0); + run(&mut s, "layer.new", json!({"top": true})); + let b = rect(&mut s, 100.0); + run(&mut s, "select.set", json!({"ids": [b.0]})); + run(&mut s, "layer.highlight", json!({"ids": []})); + assert!(s.execute("layer.merge", &json!({})).is_err()); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + let into = id(&run(&mut s, "layer.merge", json!({}))); + let st = s.doc().unwrap(); + assert_eq!(st.doc.layers.len(), 1); + assert!(st.doc.layer_of(a) == Some(into) && st.doc.layer_of(b) == Some(into)); +} + /// Collect in New Layer puts the new layer beside the topmost row when that row is directly in a /// layer, and on top of the layer holding its group when it is in a group. When that row is in a /// hidden or locked layer, the selected art it collects from another layer is deselected, the key diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 7fe651ced..0642274ea 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -1461,6 +1461,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let rows = st.highlighted_rows(); let current = st.current_layer(); let has_sel = !st.selection.is_empty(); + // How many top-level layers hold the selected art: Merge Selected merges them. + let art_layers = st.selection.objects.iter().filter_map(|o| doc.layer_of(*o)).collect::>().len(); let isolated = st.isolation.is_some(); let targets: Vec = if rows.is_empty() { current.into_iter().collect() } else { rows.clone() }; let one_container = match targets.as_slice() { @@ -1498,7 +1500,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { None, Some(("Locate Object", "layer.locate", json!({}), has_sel, false)), None, - Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2, false)), + Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2 || (rows.is_empty() && art_layers >= 2), false)), Some(("Flatten Artwork", "layer.flatten", json!({}), true, false)), Some(("Collect in New Layer", "layer.collectInNew", json!({}), !rows.is_empty() || has_sel, false)), // Sentinel: rendered as Collect for Export › As Single / As Multiple (assets.add). From 1fbd35560d0c57441ca3ad4207704ff54f5cd6c5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:06:47 +0700 Subject: [PATCH 026/208] Tests: the cursor-theme test compares paths, so it passes on Windows (#2450) --- apps/vectorcraft/src/cursor_theme.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/vectorcraft/src/cursor_theme.rs b/apps/vectorcraft/src/cursor_theme.rs index b0a6730df..ebb8ce871 100644 --- a/apps/vectorcraft/src/cursor_theme.rs +++ b/apps/vectorcraft/src/cursor_theme.rs @@ -181,11 +181,16 @@ mod tests { #[test] fn a_theme_is_usable_directly_or_through_inherits_and_missing_otherwise() { let files = ["/i/Bibata/cursors/left_ptr", "/i/Breeze/index.theme", "/i/Loop/index.theme"]; - let exists = |p: &Path| p.to_str().is_some_and(|p| files.contains(&p)); - let read = |p: &Path| match p.to_str()? { - "/i/Breeze/index.theme" => Some("[Icon Theme]\nInherits=Other, Bibata\n".to_owned()), - "/i/Loop/index.theme" => Some("Inherits=Loop\n".to_owned()), - _ => None, + // Compared as paths, so the joins match on Windows too, where they use `\`. + let exists = |p: &Path| files.iter().any(|f| Path::new(f) == p); + let read = |p: &Path| { + if p == Path::new("/i/Breeze/index.theme") { + Some("[Icon Theme]\nInherits=Other, Bibata\n".to_owned()) + } else if p == Path::new("/i/Loop/index.theme") { + Some("Inherits=Loop\n".to_owned()) + } else { + None + } }; let with = |theme: &'static str| { move |n: &str| (n == "XCURSOR_PATH").then(|| OsString::from("/i")).or((n == "XCURSOR_THEME").then(|| OsString::from(theme))) From 57c79dc0b4d0b0330907a20c8f50a1d157ff96bf Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 21:17:17 +0200 Subject: [PATCH 027/208] PNG export: an sRGB chunk marks the colours as sRGB, embedIcc turns it off (#2221) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/encode.rs | 3 +++ crates/engine/src/cmd/fileio/mod.rs | 6 +++--- crates/engine/src/cmd/fileio/tests_raster.rs | 15 +++++++++++++++ docs/file-format-parity.md | 2 +- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 09f6a4d08..7c26de22a 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -479,6 +479,9 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { } else { bytes }; + // PNG: say the colours are sRGB (what the renderer draws), unless `embedIcc` is false. + let bytes = + if format == RasterFormat::Png && settings.jpeg.embed_icc { vectorcraft_render::encode::png::with_srgb(bytes) } else { bytes }; enc.files.push((Some(b), bytes)); } if enc.files.is_empty() { diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 93282d33d..b85fa7306 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -123,7 +123,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -348,7 +348,7 @@ const EMBED_ICC: FormatOption = FormatOption { name: "embedIcc", ty: "boolean", default: "true", - description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve", + description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve; PNG: mark the file as sRGB (an sRGB chunk)", }; const IMAGE_MAP: FormatOption = FormatOption { name: "imageMap", @@ -560,7 +560,7 @@ pub const FORMATS: &[Format] = &[ read: true, write: true, raster: true, - options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED], + options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED, EMBED_ICC], }, Format { id: "jpg", diff --git a/crates/engine/src/cmd/fileio/tests_raster.rs b/crates/engine/src/cmd/fileio/tests_raster.rs index 110847ed5..fbcb9ad58 100644 --- a/crates/engine/src/cmd/fileio/tests_raster.rs +++ b/crates/engine/src/cmd/fileio/tests_raster.rs @@ -159,6 +159,21 @@ fn black_output_rich_covers_ink_plane_documents_and_leaves_cmyk_exports_alone() assert!(inks == [cmyk(&mut s, "tiff"), cmyk(&mut s, "jpg")], "CMYK exports keep the document's inks"); } +#[test] +fn png_says_it_is_srgb_unless_embed_icc_is_false() { + let mut s = session(40.0, 30.0, 1); + s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap(); + let png = |s: &mut Session, p: Value| b64(&s.execute("document.export", &p).unwrap()); + let on = png(&mut s, json!({"format": "png"})); + assert_eq!(png_chunk(&on, b"sRGB"), Some(&[0u8][..]), "an sRGB chunk, perceptual intent"); + let off = png(&mut s, json!({"format": "png", "embedIcc": false})); + assert!(png_chunk(&off, b"sRGB").is_none()); + assert_eq!(rgba(&on), rgba(&off), "same pixels either way"); + let r = s.execute("document.formats", &json!({})).unwrap(); + let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap(); + assert!(f["options"].get("embedIcc").is_some(), "png lists embedIcc"); +} + #[test] fn formats_list_the_raster_options() { let mut s = Session::new(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b848ff79b..d0010471b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -91,7 +91,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | | PSD | open, place, export | yes (merged image, every mode and depth) | yes (layered, max editability) | placing as layers (Photoshop Import Options) missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | | PSB | open | yes (merged image) | no | | -| PNG / JPEG / GIF / BMP / TIFF / Targa | open, export | yes (Targa: export only) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa import missing; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), CCITT Group 3 and planar files with extra channels don't; the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584) | +| PNG / JPEG / GIF / BMP / TIFF / Targa | open, export | yes (Targa: export only) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa import missing; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), CCITT Group 3 and planar files with extra channels don't; the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | | WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | | HEIC / HEIF | open, place | no | no | | | JPEG 2000 | open, place | no | no | | From 5fd7fd43121c7e7a05991fe7718d7f0994a67b98 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 21:15:20 +0200 Subject: [PATCH 028/208] Character: the Kerning field at a caret kerns the pair, not the whole text (#2217) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/character.rs | 48 +++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 21d0ce6fb..a36edc790 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -147,6 +147,28 @@ fn char_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) { range_style(app, p); } +/// Set the Kerning field's `value` (a number or "auto"). At a caret in text the Type tool edits +/// it is the pair kerning after the character before the caret, as Alt+←/→ steps it +/// (`type.step`); nowhere to set at the start of the text. Else it is a character attribute like +/// the others: the selected range, or all the text. +fn set_kerning(app: &mut VectorcraftApp, value: Value) { + let Some((id, a, b)) = text_editing(app) else { + format(app, json!({"kerning": value})); + return; + }; + if b > a { + range_style(app, json!({"kerning": value})); + return; + } + let Some(NodeKind::Text(t)) = app.session.active().and_then(|st| st.doc.node(id)).map(|n| &n.kind) else { return }; + let start = vectorcraft_text::edit::prev_char(&t.plain_text(), a); + if start >= a { + return; + } + end_typing(app); + app.run("text.setRangeStyle", json!({"id": id.0, "start": start, "end": a, "kerning": value})).ok(); +} + /// The faces Type › Bold and Type › Italic switch to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Face { @@ -450,7 +472,7 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: & cell(ui, "VA", tl!("Kerning (0 = Auto)"), |ui| { let kerning = shared(app, |s| s.kerning.unwrap_or(0.0)); if let Some(v) = widgets::spin_plain(ui, "ch-kern", kerning, "", 0, fw, 10.0, -1000.0, &TRACKING_PRESETS) { - format(app, if v == 0.0 { json!({"kerning": "auto"}) } else { json!({"kerning": v}) }); + set_kerning(app, if v == 0.0 { json!("auto") } else { json!(v) }); } }); cell(ui, "VA↔", tl!("Tracking"), |ui| { @@ -783,6 +805,30 @@ mod tests { assert_eq!(text_style(&app).unwrap().0.size, 5.0); } + /// The Kerning field at a caret kerns that pair, not the whole text (#2217). + #[test] + fn kerning_at_a_caret_sets_the_pair_not_the_whole_text() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "AVAX", "size": 12})).unwrap()["id"].clone(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 2, "end": 2})); + set_kerning(&mut app, json!(-50)); + let kerning = |app: &VectorcraftApp, at: usize| { + let st = app.session.active().unwrap(); + let Some(NodeKind::Text(t)) = st.doc.node(NodeId(id.as_u64().unwrap())).map(|n| &n.kind) else { panic!("text") }; + vectorcraft_text::edit::style_at(&t.runs, at + 1).kerning // the character at `at` + }; + assert_eq!(kerning(&app, 1), Some(-50.0), "the character before the caret"); + assert_eq!(kerning(&app, 3), None, "the rest is untouched"); + assert_eq!(kerning(&app, 0), None); + // A selected range still takes it whole. + app.session.set_tool_option("select", &json!({"start": 2, "end": 4})); + set_kerning(&mut app, json!(30)); + assert_eq!((kerning(&app, 1), kerning(&app, 2), kerning(&app, 3)), (Some(-50.0), Some(30.0), Some(30.0))); + } + /// Bold and Italic pick the family's own faces by name, and toggle back (#724). #[test] fn bold_and_italic_pick_the_familys_own_faces() { From d39e02b3fe6b4ec744eab0803c0b8f7882888f2b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 21:14:46 +0200 Subject: [PATCH 029/208] Type: selected overflowing type shows its red out port with any tool (#2323) The overflow marker was drawn by the Type tools only, so with the Selection tool a text box smaller than its text showed no sign that text was hidden. Session::overlays now adds the marker of every selected area type and type on a path that overflows, unless a Type tool is active (it draws its own). The verdict is cached per object and font generation. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 3 ++ crates/engine/src/tests_textedit.rs | 20 +++++++++ crates/engine/src/tooling.rs | 6 +++ crates/tools/src/text.rs | 65 ++++++++++++++++++++++------- crates/tools/src/text_tests.rs | 18 ++++++++ 5 files changed, 96 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a16fc502e..395a83910 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1198,6 +1198,8 @@ pub struct Session { pub(crate) plane_widget_press: bool, /// A guide being dragged out of a ruler ([`Session::ruler_guide`]). pub(crate) ruler_guide: Option, + /// Which selected type overflows its frame, for the markers every tool but the Type tools shows. + pub(crate) overflow_marks: vectorcraft_tools::text::OverflowMarks, /// The last search for the files of missing fonts (`text.findFontFiles`), until another /// starts; dropping it stops it. pub(crate) font_search: Option, @@ -1268,6 +1270,7 @@ impl Session { liquify_stroke: None, plane_widget_press: false, ruler_guide: None, + overflow_marks: Default::default(), font_search: None, link_search: None, search_rules: None, diff --git a/crates/engine/src/tests_textedit.rs b/crates/engine/src/tests_textedit.rs index 28ac51c9a..26713fa92 100644 --- a/crates/engine/src/tests_textedit.rs +++ b/crates/engine/src/tests_textedit.rs @@ -737,3 +737,23 @@ fn a_colour_previewed_while_typing_keeps_the_typing() { s.tool_text("d", v).unwrap(); assert_eq!(obj(&s, id).plain_text(), "abcd"); } + +/// The red out port of selected overflowing area type is drawn with the Selection tool too, and +/// not twice with the Type tool, which draws it itself (#2323). +#[test] +fn overflow_marker_shows_with_the_selection_tool() { + let mut s = session(); + let v = ViewInfo::default(); + let long = "Words that fill more than the frame holds. ".repeat(20); + let r = s.execute("text.create", &json!({"x": 50, "y": 50, "text": long, "size": 20, "area": {"width": 100, "height": 40}})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.select_tool("selection", v).unwrap(); + let red = |s: &mut Session| s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Path { color: [230, 30, 30], .. })).count(); + assert_eq!(red(&mut s), 1, "the out port"); + s.select_tool("type", v).unwrap(); + assert_eq!(red(&mut s), 1, "the Type tool's own, not a second one"); + s.execute("select.set", &json!({"ids": []})).unwrap(); + s.select_tool("selection", v).unwrap(); + assert_eq!(red(&mut s), 0, "nothing selected"); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 9fd526e9e..b8609689f 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -399,6 +399,12 @@ impl Session { pub fn overlays(&mut self, view: ViewInfo) -> Vec { let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx)); + // The Type tools draw the overflow markers of selected type themselves. + if !matches!(self.tool.id(), "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath") { + let mut marks = std::mem::take(&mut self.overflow_marks); + v.extend(self.with_tool_cx(view, |_, cx| vectorcraft_tools::text::overflow_markers(&mut marks, cx))); + self.overflow_marks = marks; + } if let Some(g) = self.ruler_guide.take() { v.extend(self.with_tool_cx(view, |_, cx| g.overlays(cx))); self.ruler_guide = Some(g); diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index a48a4b366..56ada31f4 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -305,26 +305,59 @@ impl TypeTool { } fn overflow_marker(t: &TextObject, lay: &TextLayout, cx: &ToolContext, o: &mut Vec) { - if !lay.overflow { - return; + if lay.overflow { + overflow_port(t, cx, o); } - // The out port: bottom-right of the frame (end of the path for type on a path). - let port = match &t.kind { - TextKind::Area { frame } => frame.bounds().map(|b| t.xf * Point::new(b.x1, b.y1)), - TextKind::OnPath { path, .. } => path.to_bezpath().segments().last().map(|s| t.xf * vectorcraft_geom::ParamCurve::end(&s)), - TextKind::Point => None, - }; - let Some(c) = port else { return }; - let h = cx.tol(4.5); - let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h); - let red = [230, 30, 30]; - o.push(Overlay::Path { path: r.to_path(0.1), color: red, width: 1.0, dashed: false }); - let (k, m) = (cx.tol(2.5), r.center()); - o.push(Overlay::Line { a: Point::new(m.x - k, m.y), b: Point::new(m.x + k, m.y), color: red, dashed: false }); - o.push(Overlay::Line { a: Point::new(m.x, m.y - k), b: Point::new(m.x, m.y + k), color: red, dashed: false }); } } +/// The red "+" out port of `t` (area type and type on a path whose text doesn't fit): at the +/// bottom right of the frame, at the end of the path for type on a path. +fn overflow_port(t: &TextObject, cx: &ToolContext, o: &mut Vec) { + let port = match &t.kind { + TextKind::Area { frame } => frame.bounds().map(|b| t.xf * Point::new(b.x1, b.y1)), + TextKind::OnPath { path, .. } => path.to_bezpath().segments().last().map(|s| t.xf * vectorcraft_geom::ParamCurve::end(&s)), + TextKind::Point => None, + }; + let Some(c) = port else { return }; + let h = cx.tol(4.5); + let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h); + let red = [230, 30, 30]; + o.push(Overlay::Path { path: r.to_path(0.1), color: red, width: 1.0, dashed: false }); + let (k, m) = (cx.tol(2.5), r.center()); + o.push(Overlay::Line { a: Point::new(m.x - k, m.y), b: Point::new(m.x + k, m.y), color: red, dashed: false }); + o.push(Overlay::Line { a: Point::new(m.x, m.y - k), b: Point::new(m.x, m.y + k), color: red, dashed: false }); +} + +/// Which selected text overflows, for [`overflow_markers`]: each text object's layout verdict kept +/// with the object and the fonts it was made from, as overlays run every frame. +#[derive(Default)] +pub struct OverflowMarks { + seen: std::collections::HashMap, +} + +/// The overflow markers of the selected area type and type on a path, for every tool but the Type +/// tools (they draw their own): an out port with a red "+" tells that text is hidden by the size of +/// its frame, whatever tool is active. +pub fn overflow_markers(marks: &mut OverflowMarks, cx: &ToolContext) -> Vec { + let fonts = FontDb::global().generation(); + let mut o = Vec::new(); + let mut kept = std::collections::HashMap::new(); + for id in &cx.selection.objects { + let Some(t) = TypeTool::text(cx, *id).filter(|t| !matches!(t.kind, TextKind::Point)) else { continue }; + let over = match marks.seen.remove(id) { + Some(((g, was), over)) if g == fonts && &was == t => over, + _ => vectorcraft_text::layout(FontDb::global(), t).overflow, + }; + if over { + overflow_port(t, cx, &mut o); + } + kept.insert(*id, ((fonts, t.clone()), over)); + } + marks.seen = kept; + o +} + /// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes). /// `None` when `r` runs past the end of `s` or backwards. /// The `type.step` an Alt+arrow asks for, by one step: ←/→ kerning at a `caret`, else tracking; diff --git a/crates/tools/src/text_tests.rs b/crates/tools/src/text_tests.rs index c3005d063..7910d2506 100644 --- a/crates/tools/src/text_tests.rs +++ b/crates/tools/src/text_tests.rs @@ -400,3 +400,21 @@ fn escape_returns_to_previous_tool_when_set() { let acts2 = key(&mut tool2, &d2, ToolKey::Escape, Mods::default()); assert_eq!(acts2.last(), Some(&Action::SwitchTool("selection".into()))); } + +/// Selected area type whose text doesn't fit its frame shows the red out port with any tool, not +/// only the Type tools (#2323); text that fits shows none. +#[test] +fn overflowing_selected_type_shows_its_marker_without_the_type_tool() { + let (mut d, id) = crate::testutil::doc_with_area_type(); + let (mut sel, p) = (Selection::default(), paint()); + sel.set([id]); + let mut marks = OverflowMarks::default(); + assert!(overflow_markers(&mut marks, &cx(&d, &sel, &p)).is_empty(), "the text fits"); + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.runs = vec![TextRun::new("Words that fill more than the frame holds. ".repeat(20), CharStyle { size: 20.0, ..Default::default() })]; + } + let ov = overflow_markers(&mut marks, &cx(&d, &sel, &p)); + assert_eq!(ov.len(), 3, "a box and a plus: {ov:?}"); + sel.clear(); + assert!(overflow_markers(&mut marks, &cx(&d, &sel, &p)).is_empty(), "only selected type shows it"); +} From 8ac4c9d268a967c28181222f6c8acc02238d8ab5 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:38:23 +0200 Subject: [PATCH 030/208] Color: Generic CMYK compensates black both ways so black round-trips (#2493) sRGB to Generic CMYK applies black-point compensation (black separates to the darkest rich black) but CMYK to display did not undo it, so hex 000000 became C67 M67 Y67 K100 and then showed as #1b1818. The display XYZ now maps the press black to black when the colour settings use black-point compensation, in the sRGB conversion, the ink lookup table the canvas uses, Lab of a CMYK colour and the gamut warning. Co-Authored-By: Claude Opus 5.5 --- crates/color/src/cms/generic.rs | 31 +++++++++++++++++++++++------- crates/color/src/cms/mod.rs | 23 +++++++++++++++------- crates/color/src/cms/tests.rs | 14 ++++++++++++++ crates/color/src/cms/tests_icc.rs | 3 ++- crates/pdf/src/tests_cmykimages.rs | 2 +- crates/render/src/ink.rs | 2 +- crates/render/src/tests_memory.rs | 3 ++- docs/target-app-parity.md | 2 +- 8 files changed, 61 insertions(+), 19 deletions(-) diff --git a/crates/color/src/cms/generic.rs b/crates/color/src/cms/generic.rs index fc1922e13..cea213cd6 100644 --- a/crates/color/src/cms/generic.rs +++ b/crates/color/src/cms/generic.rs @@ -171,9 +171,25 @@ impl GenericCmyk { xyz_to_lab(self.to_xyz(cmyk)) } - /// Display sRGB: media-relative (paper shows as white) or absolute (simulate paper colour). - pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] { - xyz_to_srgb(if absolute { self.to_xyz(cmyk) } else { self.to_xyz_rel(cmyk) }) + /// Display XYZ: media-relative (paper shows as white) or absolute (simulate paper colour). + /// With `bpc` (media-relative only) the press black displays as black, undoing the + /// compensation [`Self::gamut_map`] applies on the way in, so sRGB black separated to the + /// darkest rich black shows as black again. + pub fn display_xyz(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] { + if absolute { + return self.to_xyz(cmyk); + } + let x = self.to_xyz_rel(cmyk); + if !bpc { + return x; + } + let bp = self.black_rel; + [0, 1, 2].map(|i| ((x[i] - bp[i]) / (1.0 - bp[i] / D50[i])).max(0.0)) + } + + /// Display sRGB, see [`Self::display_xyz`]. + pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] { + xyz_to_srgb(self.display_xyz(cmyk, absolute, bpc)) } /// Lab of the darkest printable black (media-relative). @@ -425,11 +441,12 @@ impl GenericCmyk { self.from_lab(srgb_to_lab(rgb), intent, bpc) } - /// ΔE2000 between an sRGB colour and its best (relative colorimetric) reproduction. - pub fn gamut_error(&self, src: Lab) -> f32 { - let target = self.gamut_map(src, Intent::RelativeColorimetric, false); + /// ΔE2000 between a colour and its best (relative colorimetric) reproduction as it displays + /// (with `bpc`, the press black showing as black). + pub fn gamut_error(&self, src: Lab, bpc: bool) -> f32 { + let target = self.gamut_map(src, Intent::RelativeColorimetric, bpc); let cmyk = self.separate(target); - delta_e2000(self.to_lab_rel(cmyk), target) + delta_e2000(xyz_to_lab(self.display_xyz(cmyk, false, bpc)), src) } } diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index 40c13bd56..9eeafdb2c 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -346,10 +346,10 @@ impl Cms { } } - fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool) -> [f32; 3] { + fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] { let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0)); match space { - CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute), + CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute, bpc), CmykSpace::Device => naive_cmyk_to_rgb(cmyk), CmykSpace::Icc(p) => { let i = if absolute { Intent::AbsoluteColorimetric } else { Intent::RelativeColorimetric }; @@ -376,7 +376,7 @@ impl Cms { { return v; } - Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute) + Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute, self.settings.bpc) } /// CMYK in the working space → working RGB with `intent`. An ICC CMYK profile converts /// directly into the RGB profile: not through display sRGB, which would clip colours a wider @@ -408,6 +408,15 @@ impl Cms { _ => lab::srgb_to_lab(self.cmyk_to_srgb(cmyk, false)), } } + /// Lab of working-space CMYK as it displays: [`Self::cmyk_to_lab`] plus, for the built-in + /// Generic CMYK, the black-point compensation of [`Self::cmyk_to_srgb`] (its press black shows + /// as black). Unclipped, for interpolating tables. + pub fn cmyk_display_lab(&self, cmyk: [f32; 4]) -> Lab { + match &self.cmyk { + CmykSpace::Generic(g) => lab::xyz_to_lab(g.display_xyz(cmyk.map(|v| v.clamp(0.0, 1.0)), false, self.settings.bpc)), + _ => self.cmyk_to_lab(cmyk), + } + } /// Lab → working CMYK with `intent`. pub fn lab_to_cmyk(&self, l: Lab, intent: Intent) -> [f32; 4] { match &self.cmyk { @@ -429,7 +438,7 @@ impl Cms { /// CIE Lab (D50) of any colour. pub fn lab(&self, c: &Color) -> Lab { match *c { - Color::Cmyk { c, m, y, k } => self.cmyk_to_lab([c, m, y, k]), + Color::Cmyk { c, m, y, k } => self.cmyk_display_lab([c, m, y, k]), Color::Lab { l, a, b } => Lab::new(l, a, b), _ => lab::srgb_to_lab(self.display_rgb(c)), } @@ -490,7 +499,7 @@ impl Cms { Color::Rgb { .. } | Color::Lab { .. } => { let src = self.lab(c); match &self.cmyk { - CmykSpace::Generic(g) => g.gamut_error(src), + CmykSpace::Generic(g) => g.gamut_error(src, self.settings.bpc), CmykSpace::Device => 0.0, CmykSpace::Icc(_) => { let back = self.cmyk_to_srgb(self.to_cmyk(c, Intent::RelativeColorimetric), false); @@ -523,7 +532,7 @@ impl Cms { ProofTarget::Cmyk(name) => match cmyk_space(name) { Ok(sp) => { let cmyk = Self::srgb_to_cmyk_in(&sp, srgb, proof.intent, self.settings.bpc); - Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper) + Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc) } Err(_) => srgb, }, @@ -534,7 +543,7 @@ impl Cms { pub fn proof_cmyk_to_srgb(&self, cmyk: [f32; 4], proof: &ProofSetup) -> [f32; 3] { match &proof.target { ProofTarget::Cmyk(name) => match cmyk_space(name) { - Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper), + Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc), Err(_) => self.cmyk_to_srgb(cmyk, proof.simulate_paper), }, _ => self.cmyk_to_srgb(cmyk, proof.simulate_paper), diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index dd9c137a0..99c16edf4 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -245,6 +245,20 @@ fn loads_system_cmyk_icc() { assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]); } +/// With black-point compensation, sRGB black separates to the press's darkest black and that black +/// displays as black again (hex 000000 -> C67 M67 Y67 K100 -> 000000, not a dark grey). +#[test] +fn generic_cmyk_black_round_trips_with_bpc() { + let c = cms(); + let cmyk = c.srgb_to_cmyk([0.0; 3], Intent::RelativeColorimetric); + let back = c.cmyk_to_srgb(cmyk, false).map(|v| (v * 255.0).round() as u8); + assert_eq!(back, [0, 0, 0], "{cmyk:?}"); + // The paper still shows as white, and without compensation the press black stays a dark grey. + assert_eq!(c.cmyk_to_srgb([0.0; 4], false), [1.0, 1.0, 1.0]); + let raw = cms_no_bpc().cmyk_to_srgb(cmyk, false); + assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}"); +} + #[test] fn lab_colours_round_trip_through_the_cms() { let c = cms_no_bpc(); diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index 2e5f099c2..4eca85781 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -22,7 +22,8 @@ fn cmyk_profiles_convert_like_the_cms() { assert_eq!(parse(&bytes).color_space, moxcms::DataColorSpace::Cmyk); let p = IccProfile::from_bytes(None, &bytes).unwrap(); assert_eq!((p.name.as_str(), p.kind), (name, ProfileKind::Cmyk)); - let cms = Cms::new(&ColorSettings { cmyk: name.into(), ..Default::default() }).unwrap(); + // The encoded profile converts without black-point compensation, so compare without it. + let cms = Cms::new(&ColorSettings { cmyk: name.into(), bpc: false, ..Default::default() }).unwrap(); for cmyk in [[0.0; 4], [1.0, 0.0, 0.0, 0.0], [0.2, 0.7, 0.1, 0.3], [0.0, 0.0, 0.0, 1.0], [0.5, 0.5, 0.5, 0.0]] { let want = cms.cmyk_to_srgb(cmyk, false); let got = p.to_srgb(&cmyk, Intent::RelativeColorimetric).unwrap(); diff --git a/crates/pdf/src/tests_cmykimages.rs b/crates/pdf/src/tests_cmykimages.rs index 421cd3c71..3d68dc044 100644 --- a/crates/pdf/src/tests_cmykimages.rs +++ b/crates/pdf/src/tests_cmykimages.rs @@ -340,7 +340,7 @@ fn the_output_settings_convert_cmyk_images_and_compression_recompresses_them_in_ assert_ne!(converted, flat(ink, 16)); let source = cms::active(); let dest = cms::Cms::new(&cms::ColorSettings { cmyk: DEVICE_CMYK.into(), ..source.settings().clone() }).unwrap(); - let want = dest.lab_to_cmyk(source.cmyk_to_lab(ink.map(|v| v as f32 / 255.0)), source.settings().intent).map(|v| (v * 255.0).round() as u8); + let want = dest.lab_to_cmyk(source.cmyk_display_lab(ink.map(|v| v as f32 / 255.0)), source.settings().intent).map(|v| (v * 255.0).round() as u8); assert!(close(&converted[..4], &want, 3), "{converted:?} vs {want:?}"); // To RGB: the colour shown on screen. let (w, blob, _) = first(json!({"output": {"conversion": "destination", "destination": SRGB}})); diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index 3483e6a56..10e4f6eea 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -153,7 +153,7 @@ pub(crate) struct InkLut(CmykLut); impl InkLut { fn build(cms: &Cms) -> Self { Self(CmykLut::build(|inks| { - let [x, y, z] = lab::lab_to_xyz(cms.cmyk_to_lab(inks)); + let [x, y, z] = lab::lab_to_xyz(cms.cmyk_display_lab(inks)); [x, y, z, 0.0] })) } diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs index 3aa83398f..46c649f2e 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -1535,7 +1535,8 @@ fn proof_previews_draw_changes_as_a_new_renderer_does() { // A square in spot color `Lab Spot`, defined in Lab in a color group, on its own plate. let lab_doc = || { let mut d = proof_doc(); - let lab = Color::lab(50.0, 60.0, 40.0); + // Outside the press gamut, so its CMYK alternate looks different from the Lab colour. + let lab = Color::lab(50.0, 90.0, 70.0); let swatch = vectorcraft_color::Swatch { name: "Lab Spot".into(), paint: Paint::solid(lab), global: true, spot: true }; d.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Lab".into(), swatches: vec![swatch] }); let fill = Paint::Solid { color: lab, swatch: Some("Lab Spot".into()), tint: 1.0 }; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4dbf73a8e..f2043138b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -570,7 +570,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). - **Paint and appearance (M3):** - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. - - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). + - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). View › Proof Setup › Color blindness – Tritanopia-type turns Proof Colors on and proofs the canvas with the tritanopia simulation of Brettel, Viénot and Mollon (1997). The Separations Preview panel's Proof Setup list offers the same target (`view.proofSetup {target: "tritanopia"}`). Grays keep their value. - **Eyedropper sources (#1251):** ordinary and Shift clicks, Shift+Alt appearance append, raster pixels and gradient-stop sampling can read visible locked/template art and sources outside isolation. Sampling leaves the source and selection intact; Alt reverse still uses editable hits, and hidden art stays excluded. Engine/tool/hit regressions cover the editing boundary, color models and one-step Undo/Redo. The general object/image workflow is described in [public HelpX](https://helpx.adobe.com/illustrator/using/tool-techniques/eyedropper-tool.html); the lock/isolation expectations come from the user report and these regressions, without a new full-parity measurement. - **Sample Color from Screen:** an eyedropper button in the Color Picker and `ui.sampleScreenColor` (which can be given a key in Keyboard Shortcuts) pick a color anywhere on the screen with the system's sampler: `NSColorSampler` on macOS, the EyeDropper API in browsers that have it. The color becomes the fill or the stroke as one undo step, or the Color Picker's color, converted from display sRGB into the working RGB space. Windows, Linux and other browsers have no sampler yet; the button is gray there. From 2a3efa02f27cb64904cc7dfb4b5706baa1b96041 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 12:48:59 +0700 Subject: [PATCH 031/208] Overprint Preview: overprinting characters draw with Darken too (#2415) --- crates/render/src/lib.rs | 4 ++-- crates/render/src/proof.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5791bd49b..24bf5a0e0 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1773,12 +1773,12 @@ impl Renderer { return; } let tb = t.xf.transform_rect_bbox(g.bounds); - // Overprinting characters multiply per draw, like overprinting fills and strokes + // Overprinting characters darken per draw, like overprinting fills and strokes // ([`proof`]); the object blend mode is Normal here or applied by an enclosing layer. let overprints = proof::overprints(f.opts); let overprint = |ctx: &mut RenderContext, on: bool| { if overprints { - ctx.set_blend_mode(if on { blend_mode(vectorcraft_color::BlendMode::Multiply) } else { BlendMode::default() }); + ctx.set_blend_mode(if on { blend_mode(vectorcraft_color::BlendMode::Darken) } else { BlendMode::default() }); } }; // The object's own fills and strokes paint the whole outline: those below the Characters diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs index 86c0319ed..e308e2b4f 100644 --- a/crates/render/src/proof.rs +++ b/crates/render/src/proof.rs @@ -103,7 +103,7 @@ impl PartialEq for Plates { } } -/// What [`prepare`] does to the fills and strokes of each node: overprinting ones multiply +/// What [`prepare`] does to the fills and strokes of each node: overprinting ones darken /// ([`darken_overprints`]), and with Separations Preview every color shows as the visible plates /// print it ([`plate_color`]). struct How<'a> { From ecbbba4f2e78ee0e5b0abac3cc978fa4e85a3521 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:31:51 +0200 Subject: [PATCH 032/208] Panels: floating panel groups paint an opaque background (#2421) The shared dock left its pane and tab strip backgrounds transparent because the docked card behind them is filled. Floating groups lie on the canvas, so the canvas showed through. They now paint the panel colour, which is what the card shows when docked. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panel_docking.rs | 8 ++++--- crates/ui-egui/src/panel_docking_tests.rs | 28 ++++++++++++++++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index d01858d3c..9cb1cdbf6 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -644,9 +644,11 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { } let mut style = craft_ui::docking::DockStyle::from_ui(ui); style.tab_height = 33.0; - // The dock is a card (`theme::card`): its panes and tab strip lie on the card's colour. - style.background = egui::Color32::TRANSPARENT; - style.tab_background = egui::Color32::TRANSPARENT; + // The dock is a card (`theme::card`) filled with the panel colour. Floating groups lie on + // the canvas, not on that card, so panes and tab strip paint the same colour themselves; + // transparent they showed the canvas through (#2421). + style.background = t.panel; + style.tab_background = t.panel; style.active_background = t.button; style.text = t.text_strong; style.inactive_text = t.text_dim; diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index c66623e75..9eccbf0e8 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -1,5 +1,5 @@ use super::*; -use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; use egui_kittest::{Harness, kittest::Queryable}; fn app() -> VectorcraftApp { @@ -614,3 +614,29 @@ fn the_window_menu_item_hides_a_shown_panel_and_shows_it_again() { app.run("window.panel", json!({"panel":"properties"})).unwrap(); assert!(shown(&app), "without the menu's toggle the command only shows the panel"); } + +/// A floating group lies on the canvas, not on the dock's card, so its tab strip and body paint +/// their own opaque background (#2421). +#[test] +fn floating_group_paints_an_opaque_background() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"properties","x":400,"y":200,"width":300,"height":420})).unwrap(); + let h = harness(app, vec2(1280.0, 800.0)); + // Paint order: the last big filled rectangle over a point decides what the user sees there + // (outlines and small widgets don't count). + fn topmost(shape: &egui::Shape, p: Pos2, found: &mut Option) { + match shape { + egui::Shape::Rect(r) if r.rect.contains(p) && r.rect.width() >= 200.0 && r.stroke.is_empty() => *found = Some(r.fill), + egui::Shape::Vec(v) => v.iter().for_each(|s| topmost(s, p, found)), + _ => {} + } + } + let opaque_at = |p: Pos2| { + let mut found = None; + h.output().shapes.iter().for_each(|s| topmost(&s.shape, p, &mut found)); + found.is_some_and(|c| c.a() == 255) + }; + // Tab strip to the right of the tabs, and the body's empty bottom-right corner. + assert!(opaque_at(pos2(690.0, 210.0)), "tab strip is transparent"); + assert!(opaque_at(pos2(690.0, 610.0)), "body is transparent"); +} From 47b6b3285db2252ebe0d697e7876f0046979aabe Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 12:50:24 +0700 Subject: [PATCH 033/208] Panels: floating groups get an opaque fill beneath them; the docked card keeps its rounded corners (#2421) --- crates/ui-egui/src/panel_docking.rs | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 9cb1cdbf6..7e1df99c4 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -625,6 +625,17 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { display .floating .retain(|group| !app.ui.floating_panels.iter().any(|native| decorated(native) && native.panels.iter().any(|id| group.panels.contains(id)))); + // Floating groups lie on the canvas, not on the dock's card: reserve a fill under each one + // before the dock draws it, sized once it is placed, so the canvas doesn't show through (#2421). + let fills: Vec<_> = display + .floating + .iter() + .filter_map(|group| group.panels.first()) + .map(|first| { + let layer = egui::LayerId::new(egui::Order::Middle, area.with(("floating", first))); + (layer, ui.ctx().layer_painter(layer).add(egui::Shape::Noop)) + }) + .collect(); let mut draw = |ui: &mut egui::Ui| { if layout.root.is_some() && crate::dock::collapse_header(ui, collapsed) { let _ = app.run("window.collapseDock", json!({"collapsed":!collapsed})); @@ -644,11 +655,10 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { } let mut style = craft_ui::docking::DockStyle::from_ui(ui); style.tab_height = 33.0; - // The dock is a card (`theme::card`) filled with the panel colour. Floating groups lie on - // the canvas, not on that card, so panes and tab strip paint the same colour themselves; - // transparent they showed the canvas through (#2421). - style.background = t.panel; - style.tab_background = t.panel; + // The dock is a card (`theme::card`): its panes and tab strip lie on the card's colour + // (square fills would cover its rounded corners). Floating groups get the fills below. + style.background = egui::Color32::TRANSPARENT; + style.tab_background = egui::Color32::TRANSPARENT; style.active_background = t.button; style.text = t.text_strong; style.inactive_text = t.text_dim; @@ -661,7 +671,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { style.resize_label = tl!("Resize panels").to_string(); style.resize_window_label = tl!("Resize panel window").to_string(); let mut content = PanelContent { app, commands: Vec::new() }; - let output = craft_ui::docking::DockArea::new(egui::Id::new("vectorcraft-panel-docking")).show_customized( + let output = craft_ui::docking::DockArea::new(area).show_customized( ui, &display, &style, @@ -756,6 +766,11 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { ui.ctx().data_mut(|data| data.insert_temp(crate::floating::group_rect_id(first), rect)); } } + for (layer, fill) in fills { + if let Some(rect) = ui.ctx().memory(|memory| memory.area_rect(layer.id)) { + ui.ctx().layer_painter(layer).set(fill, egui::Shape::rect_filled(rect, 0.0, t.panel)); + } + } icon_rail(app, ui); crate::floating::docked_columns(app, ui); true From 424a49604cc3131a4c5bab9d32bcca257e957580 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 12:49:08 +0700 Subject: [PATCH 034/208] Layers: Merge Selected keeps to the highlighted layers, as documented (#2383) Reverts the fallback that merged the layers of art selected on the canvas when no layer row is highlighted: Illustrator's Merge Selected merges the highlighted rows into the last one highlighted. --- crates/engine/src/cmd/layerpanel.rs | 13 ++----------- crates/engine/src/tests_layers.rs | 18 ------------------ crates/ui-egui/src/panels/layers.rs | 4 +--- 3 files changed, 3 insertions(+), 32 deletions(-) diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index 66cf37ff7..c302a7409 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -263,22 +263,13 @@ fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> { fn merge(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.merge"; - let mut ids = rows_param(s, p, C)?; + let ids = rows_param(s, p, C)?; let st = s.doc()?; let d = &st.doc; - // Nothing named or highlighted, but art selected on the canvas in several layers (Select All): - // merge those layers (#2383). - if p.get("ids").is_none() && p.get("id").is_none() && st.highlighted_rows().is_empty() { - let held: HashSet = st.selection.objects.iter().filter_map(|o| d.layer_of(*o)).collect(); - let layers: Vec = d.layers.iter().map(|l| l.id).filter(|l| held.contains(l)).collect(); - if layers.len() >= 2 { - ids = layers; - } - } let mut seen = HashSet::new(); let ids: Vec = ids.into_iter().filter(|id| d.node(*id).is_some() && seen.insert(*id)).collect(); if ids.len() < 2 { - return Err(bad(C, "highlight two or more rows to merge, or select art in two or more layers")); + return Err(bad(C, "highlight two or more rows to merge")); } // The last highlighted layer that is not inside another highlighted row: with a layer and its // sublayer both highlighted (Select All in the panel), the sublayer travels with its layer diff --git a/crates/engine/src/tests_layers.rs b/crates/engine/src/tests_layers.rs index 7591873d2..1fc789c80 100644 --- a/crates/engine/src/tests_layers.rs +++ b/crates/engine/src/tests_layers.rs @@ -625,24 +625,6 @@ fn merge_selected_with_all_layer_rows_highlighted_merges_into_the_last_top_level } } -/// Merge Selected with no highlighted rows but art selected in several layers (Select All on the -/// canvas) merges those layers; art in one layer still asks for rows (#2383). -#[test] -fn merge_selected_with_art_selected_in_several_layers_merges_those_layers() { - let mut s = session(); - let a = rect(&mut s, 0.0); - run(&mut s, "layer.new", json!({"top": true})); - let b = rect(&mut s, 100.0); - run(&mut s, "select.set", json!({"ids": [b.0]})); - run(&mut s, "layer.highlight", json!({"ids": []})); - assert!(s.execute("layer.merge", &json!({})).is_err()); - run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); - let into = id(&run(&mut s, "layer.merge", json!({}))); - let st = s.doc().unwrap(); - assert_eq!(st.doc.layers.len(), 1); - assert!(st.doc.layer_of(a) == Some(into) && st.doc.layer_of(b) == Some(into)); -} - /// Collect in New Layer puts the new layer beside the topmost row when that row is directly in a /// layer, and on top of the layer holding its group when it is in a group. When that row is in a /// hidden or locked layer, the selected art it collects from another layer is deselected, the key diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 0642274ea..7fe651ced 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -1461,8 +1461,6 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let rows = st.highlighted_rows(); let current = st.current_layer(); let has_sel = !st.selection.is_empty(); - // How many top-level layers hold the selected art: Merge Selected merges them. - let art_layers = st.selection.objects.iter().filter_map(|o| doc.layer_of(*o)).collect::>().len(); let isolated = st.isolation.is_some(); let targets: Vec = if rows.is_empty() { current.into_iter().collect() } else { rows.clone() }; let one_container = match targets.as_slice() { @@ -1500,7 +1498,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { None, Some(("Locate Object", "layer.locate", json!({}), has_sel, false)), None, - Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2 || (rows.is_empty() && art_layers >= 2), false)), + Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2, false)), Some(("Flatten Artwork", "layer.flatten", json!({}), true, false)), Some(("Collect in New Layer", "layer.collectInNew", json!({}), !rows.is_empty() || has_sel, false)), // Sentinel: rendered as Collect for Export › As Single / As Multiple (assets.add). From 39979d47531d6125b948becead089bba8a59e6e4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:20:19 +0700 Subject: [PATCH 035/208] Tests: the floating-panel test skips fills that paint nothing (#2421) --- crates/ui-egui/src/panel_docking_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 9eccbf0e8..f0a556d51 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -623,10 +623,10 @@ fn floating_group_paints_an_opaque_background() { app.run("window.panel.float", json!({"panel":"properties","x":400,"y":200,"width":300,"height":420})).unwrap(); let h = harness(app, vec2(1280.0, 800.0)); // Paint order: the last big filled rectangle over a point decides what the user sees there - // (outlines and small widgets don't count). + // (outlines, small widgets and fully transparent fills, which paint nothing, don't count). fn topmost(shape: &egui::Shape, p: Pos2, found: &mut Option) { match shape { - egui::Shape::Rect(r) if r.rect.contains(p) && r.rect.width() >= 200.0 && r.stroke.is_empty() => *found = Some(r.fill), + egui::Shape::Rect(r) if r.rect.contains(p) && r.rect.width() >= 200.0 && r.stroke.is_empty() && r.fill.a() > 0 => *found = Some(r.fill), egui::Shape::Vec(v) => v.iter().for_each(|s| topmost(s, p, found)), _ => {} } From 577239d734bf2c556efc6cb897c44982f269fb39 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:26:32 +0200 Subject: [PATCH 036/208] Gradient chips: follow the angle and the gradient type (#2494) gradient_chip now takes the GradientPaint and lays the gradient out as it would be on an object of the chip's shape: linear along its angle, radial from the centre, freeform from its colour points. Gradient panel swatch menu and the Swatch Options dialog carry the full paint. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/swatch_options.rs | 4 +- crates/ui-egui/src/panels/gradient.rs | 6 +- crates/ui-egui/src/toolbar.rs | 2 +- crates/ui-egui/src/widgets.rs | 118 +++++++++++++++---- 4 files changed, 100 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/dialogs/swatch_options.rs b/crates/ui-egui/src/dialogs/swatch_options.rs index 9d6feca10..07674ef99 100644 --- a/crates/ui-egui/src/dialogs/swatch_options.rs +++ b/crates/ui-egui/src/dialogs/swatch_options.rs @@ -76,7 +76,7 @@ pub fn open(app: &mut VectorcraftApp, name: &str) -> Result { Ok(Value::Null) } Paint::Gradient(g) => { - let fields = json!({"__swatch": name, "name": name, "__gradient": g.gradient, "preview": true}); + let fields = json!({"__swatch": name, "name": name, "__gradient": g, "preview": true}); app.ui.dialog = Some(Dialog::new(KIND, fields)); Ok(Value::Null) } @@ -105,7 +105,7 @@ fn edit_params(d: &Dialog) -> Value { } fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { - let changed = match d.fields.get("__gradient").and_then(|g| serde_json::from_value::(g.clone()).ok()) { + let changed = match d.fields.get("__gradient").and_then(|g| serde_json::from_value::(g.clone()).ok()) { Some(g) => { let changed = grid(ui, |ui| name_row(ui, d)); ui.add_space(6.0); diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index abf1f60ac..0c91c0089 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -165,14 +165,14 @@ fn save_to_swatches(app: &mut VectorcraftApp, g: &GradientPaint) { } /// The document's gradient swatches (groups included): name and gradient. -fn gradient_swatches(app: &VectorcraftApp) -> Vec<(String, Gradient)> { +fn gradient_swatches(app: &VectorcraftApp) -> Vec<(String, GradientPaint)> { let Some(st) = app.session.active() else { return vec![] }; let d = &st.doc; d.swatches .iter() .chain(d.swatch_groups.iter().flat_map(|g| g.swatches.iter())) .filter_map(|s| match &s.paint { - Paint::Gradient(g) => Some((s.name.clone(), g.gradient.clone())), + Paint::Gradient(g) => Some((s.name.clone(), (**g).clone())), _ => None, }) .collect() @@ -370,7 +370,7 @@ fn freeform_section(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint) { fn thumbnail(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: bool) { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click_and_drag()); - widgets::gradient_chip(ui, r, &g.gradient); + widgets::gradient_chip(ui, r, g); ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); let resp = resp.on_hover_text(tl!("Gradient Fill: click to apply, drag onto art")); if resp.clicked() && !is_grad { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 0f9de6456..e46b86498 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -471,7 +471,7 @@ fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, cols: usiz let (r, resp) = ui.allocate_exact_size(vec2(chip, chip), Sense::click()); match cmd { "paint.lastColor" => widgets::paint_chip(ui, r, &Paint::solid(app.session.last_solid)), - "paint.lastGradient" => widgets::gradient_chip(ui, r, &app.session.last_gradient.gradient), + "paint.lastGradient" => widgets::gradient_chip(ui, r, &app.session.last_gradient), _ => widgets::paint_chip(ui, r, &Paint::None), } if resp.on_hover_text(tl!(tip)).clicked() { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 945c2c3d7..3d0096cd5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -554,7 +554,7 @@ pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) { let [r, g, b, _] = color.to_rgba8(1.0); p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b)); } - Paint::Gradient(gp) => gradient_chip(ui, rect, &gp.gradient), + Paint::Gradient(gp) => gradient_chip(ui, rect, gp), Paint::Pattern { .. } => { p.rect_filled(rect, 0.0, Color32::from_gray(200)); for i in 0..4 { @@ -565,32 +565,68 @@ pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) { } } -/// Draw a gradient preview into `rect` (linear left to right; radial from the centre). -pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient) { - let p = ui.painter(); - let radial = gradient.kind == vectorcraft_color::GradientKind::Radial; - if radial { - // Outer colour fills the corners beyond the largest circle. - let (c, _) = gradient.sample(1.0); - let [r, g, b, _] = c.to_rgba8(1.0); - p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b)); +/// Draw a gradient preview into `rect`, laid out as the gradient would be on an object of that +/// shape: linear along its angle, radial from the centre, freeform from its colour points. +pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::GradientPaint) { + if !(rect.width() > 0.0 && rect.height() > 0.0) { + return; } - let n = 24; - let w = rect.width() / n as f32; - for i in 0..n { - let tt = (i as f32 + 0.5) / n as f32; - if radial { - let (c, _) = gradient.sample(1.0 - tt); - let [r, g, b, _] = c.to_rgba8(1.0); - let s = rect.width().min(rect.height()) * tt / 2.0; - p.circle_filled(rect.center(), s.max(0.5), Color32::from_rgb(r, g, b)); - } else { - let (c, _) = gradient.sample(tt); - let [r, g, b, _] = c.to_rgba8(1.0); - let rr = Rect::from_min_size(pos2(rect.left() + i as f32 * w, rect.top()), vec2(w + 0.5, rect.height())); - p.rect_filled(rr, 0.0, Color32::from_rgb(r, g, b)); + // Freeform sampling is costly per spot, so it gets a coarse grid (the mesh interpolates). + let freeform = gradient.gradient.kind == vectorcraft_color::GradientKind::Freeform; + let max = if freeform { 16 } else { 64 }; + let cols = ((rect.width() / 3.0).ceil() as usize).clamp(1, max); + let rows = ((rect.height() / 3.0).ceil() as usize).clamp(1, max); + let colors = gradient_chip_colors(gradient, rect.width(), rect.height(), cols, rows); + let mut mesh = egui::Mesh::default(); + for (k, color) in colors.into_iter().enumerate() { + let (i, j) = (k % (cols + 1), k / (cols + 1)); + let at = pos2(rect.left() + rect.width() * i as f32 / cols as f32, rect.top() + rect.height() * j as f32 / rows as f32); + mesh.colored_vertex(at, color); + } + for j in 0..rows as u32 { + for i in 0..cols as u32 { + let v = j * (cols as u32 + 1) + i; + mesh.add_triangle(v, v + 1, v + cols as u32 + 1); + mesh.add_triangle(v + 1, v + cols as u32 + 2, v + cols as u32 + 1); } } + ui.painter().add(egui::Shape::mesh(mesh)); +} + +/// The colours at the `(cols + 1) x (rows + 1)` grid points (row by row) of a `width` x `height` +/// chip showing `gp` as it would look on an object of that size. +fn gradient_chip_colors(gp: &vectorcraft_color::GradientPaint, width: f32, height: f32, cols: usize, rows: usize) -> Vec { + use vectorcraft_color::{GradientGeom, GradientKind}; + let b = vectorcraft_geom::kurbo::Rect::new(0.0, 0.0, width as f64, height as f64); + let rgb = |c: vectorcraft_color::Color| { + let [r, g, b, _] = c.to_rgba8(1.0); + Color32::from_rgb(r, g, b) + }; + let at = |k: usize| { + let (i, j) = (k % (cols + 1), k / (cols + 1)); + vectorcraft_geom::kurbo::Point::new(b.width() * i as f64 / cols as f64, b.height() * j as f64 / rows as f64) + }; + let n = (cols + 1) * (rows + 1); + if gp.gradient.kind == GradientKind::Freeform { + let Some(painted) = vectorcraft_color::freeform::painted_box(b) else { return vec![Color32::TRANSPARENT; n] }; + let field = gp.freeform_on(painted).field(vectorcraft_color::freeform::spread_scale(painted)); + return (0..n) + .map(|k| { + let ([r, g, b], _) = field.sample(at(k)); + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(q(r), q(g), q(b)) + }) + .collect(); + } + // A placed gradient keeps its direction; a fresh one is fitted to the chip at its angle. + let angle = gp.geom.map_or(gp.angle, |g| g.angle_deg()); + let geom = GradientGeom::fit(gp.gradient.kind, b, if angle.is_finite() { angle } else { 0.0 }); + (0..n) + .map(|k| { + let t = geom.param_at(gp.gradient.kind, at(k)).clamp(0.0, 1.0) as f32; + rgb(gp.gradient.sample(t).0) + }) + .collect() } /// A Fill chip of `paint` into `rect` or, with `ring`, a Stroke chip: a frame `ring` wide round a @@ -2041,3 +2077,37 @@ mod menu_room_tests { assert_eq!(menu_room(300.0, 290.0), 120.0); } } + +#[cfg(test)] +mod gradient_chip_tests { + use super::*; + use vectorcraft_color::{Gradient, GradientKind, GradientPaint}; + + fn paint(kind: GradientKind, angle: f64) -> GradientPaint { + let mut gp = GradientPaint::new(Gradient { kind, ..Gradient::default() }); + gp.angle = angle; + gp + } + + #[test] + fn linear_chip_follows_the_angle() { + // 5 x 5 grid points on a 20 x 20 chip; White (start) to Black (end). + let at = |colors: &[Color32], i: usize, j: usize| colors[j * 5 + i]; + let side = gradient_chip_colors(&paint(GradientKind::Linear, 0.0), 20.0, 20.0, 4, 4); + assert_eq!(at(&side, 0, 2), Color32::WHITE); + assert_eq!(at(&side, 4, 2), Color32::BLACK); + assert_eq!(at(&side, 2, 0), at(&side, 2, 4)); + // 90 degrees runs bottom to top: constant along rows, white at the bottom. + let up = gradient_chip_colors(&paint(GradientKind::Linear, 90.0), 20.0, 20.0, 4, 4); + assert_eq!(at(&up, 0, 4), Color32::WHITE); + assert_eq!(at(&up, 0, 0), Color32::BLACK); + assert_eq!(at(&up, 0, 2), at(&up, 4, 2)); + } + + #[test] + fn radial_chip_runs_from_the_centre_out() { + let colors = gradient_chip_colors(&paint(GradientKind::Radial, 0.0), 20.0, 20.0, 4, 4); + assert_eq!(colors[2 * 5 + 2], Color32::WHITE); + assert_eq!(colors[0], Color32::BLACK); + } +} From 91fa3af21d3ba4ab94f88ad810aeb42a4df15496 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 21:03:42 +0200 Subject: [PATCH 037/208] Export: raster document.export takes a page rectangle (rect) (#2412) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/encode.rs | 35 ++++++++++++++++++ crates/engine/src/cmd/fileio/mod.rs | 20 ++++++++--- crates/engine/src/cmd/fileio/tests_raster.rs | 38 ++++++++++++++++++++ crates/engine/tests/command_sweep.rs | 5 +++ docs/mcp.md | 2 +- 5 files changed, 94 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 7c26de22a..f37521efa 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -307,6 +307,32 @@ fn use_artboards(p: &Value) -> Result> { } } +/// The `rect` param of a raster export: `[x, y, width, height]` in points, y down. `None` when +/// absent; an error when it is not four finite numbers with a positive size, when the format isn't +/// a raster one, or when it comes with an artboard choice (`rect` is the area, so those would +/// contradict it). The image size limits are checked with the scale, as for an artboard. +fn page_rect(f: &Format, p: &Value) -> Result> { + let v = match p.get("rect") { + None | Some(Value::Null) => return Ok(None), + Some(v) => v, + }; + if !f.raster { + return Err(bad(C, format!("rect applies to raster formats, not {}", f.label))); + } + if let Some(k) = ARTBOARD_PARAMS.iter().chain(["useArtboards"].iter()).find(|k| p.get(**k).is_some_and(|v| !v.is_null())) { + return Err(bad(C, format!("rect and {k} contradict each other: give the area or the artboards"))); + } + let nums: Option> = v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(Value::as_f64).collect()); + let Some(&[x, y, w, h]) = nums.as_deref() else { + return Err(bad(C, "rect is [x, y, width, height] in points")); + }; + let (x1, y1) = (x + w, y + h); + if !(x.is_finite() && y.is_finite() && w > 0.0 && h > 0.0 && x1.is_finite() && y1.is_finite()) { + return Err(bad(C, "rect needs finite numbers and a positive width and height")); + } + Ok(Some(Rect::new(x, y, x1, y1))) +} + /// A copy of `doc` with one artboard, `rect` (exports of the art's or the selection's bounds). pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Document { with_single_artboard(doc.clone(), rect, name) @@ -408,6 +434,15 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { let n = doc.artboards.len(); // SVG reads its own `useArtboards` (an SVG option). let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None }; + // `rect`: that area of the page, as if it were the only artboard (the document is not changed). + if let Some(rect) = page_rect(f, p)? { + let mut q = super::export::without_artboards(p); + if let Some(o) = q.as_object_mut() { + o.remove("rect"); + o.remove("useArtboards"); + } + return encode_files(&single_artboard(doc, rect, "Rect"), f, &q); + } if use_artboards == Some(false) { let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; let mut q = super::export::without_artboards(p); diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index b85fa7306..b20e0db25 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -123,7 +123,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -311,6 +311,12 @@ const USE_ARTBOARDS: FormatOption = FormatOption { default: "null", description: "true: every chosen artboard (default all), one file each named -. (PDF and AI: one page each); false: the bounds of the visible art", }; +const RECT: FormatOption = FormatOption { + name: "rect", + ty: "array", + default: "null", + description: "[x, y, width, height] in document points, y down: exports that area of the page (no artboard needed, the document is not changed); not with artboard, artboards, range or useArtboards", +}; const PPI: FormatOption = FormatOption { name: "ppi", ty: "number", @@ -398,6 +404,7 @@ const PALETTE_OPTIONS: &[FormatOption] = &[ ARTBOARDS, RANGE, USE_ARTBOARDS, + RECT, PPI, SCALE, BACKGROUND, @@ -419,7 +426,7 @@ const BYTE_ORDER: FormatOption = FormatOption { description: "the order of the bytes in the file's numbers: little (II, as on PCs) or big (MM)", }; const TIFF_OPTIONS: &[FormatOption] = - &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC]; + &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC]; const BMP_COLOR_MODEL: FormatOption = FormatOption { name: "colorModel", ty: "string", default: "\"rgb\"", description: "rgb or gray (greys at every depth)" }; const BMP_DEPTH: FormatOption = FormatOption { @@ -447,6 +454,7 @@ const BMP_OPTIONS: &[FormatOption] = &[ ARTBOARDS, RANGE, USE_ARTBOARDS, + RECT, PPI, SCALE, BACKGROUND, @@ -466,7 +474,7 @@ const TGA_DEPTH: FormatOption = FormatOption { default: "24", description: "bits per pixel: 16 (one-bit alpha), 24 (flattened on white) or 32 (keeps transparency)", }; -const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH]; +const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH]; const PSD_LAYERS: FormatOption = FormatOption { name: "layers", ty: "boolean", @@ -490,6 +498,7 @@ const PSD_OPTIONS: &[FormatOption] = &[ ARTBOARDS, RANGE, USE_ARTBOARDS, + RECT, PPI, SCALE, BACKGROUND, @@ -560,7 +569,7 @@ pub const FORMATS: &[Format] = &[ read: true, write: true, raster: true, - options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED, EMBED_ICC], + options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED, EMBED_ICC], }, Format { id: "jpg", @@ -575,6 +584,7 @@ pub const FORMATS: &[Format] = &[ ARTBOARDS, RANGE, USE_ARTBOARDS, + RECT, PPI, SCALE, BACKGROUND, @@ -596,7 +606,7 @@ pub const FORMATS: &[Format] = &[ read: true, write: true, raster: true, - options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY], + options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY], }, Format { id: "tiff", diff --git a/crates/engine/src/cmd/fileio/tests_raster.rs b/crates/engine/src/cmd/fileio/tests_raster.rs index fbcb9ad58..496b16f81 100644 --- a/crates/engine/src/cmd/fileio/tests_raster.rs +++ b/crates/engine/src/cmd/fileio/tests_raster.rs @@ -295,3 +295,41 @@ fn a_raster_export_says_when_an_image_could_not_be_decoded() { let warnings = r["warnings"].as_array().cloned().unwrap_or_default(); assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "{r}"); } + +#[test] +fn export_rect_writes_that_page_area_and_leaves_the_document_alone() { + let mut s = session(200.0, 400.0, 1); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap(); + let boards = s.doc().unwrap().doc.artboards.len(); + let r = s.execute("document.export", &json!({"format": "png", "rect": [20, 40, 40, 300], "ppi": 72})).unwrap(); + let img = rgba(&b64(&r)); + assert_eq!((img.width(), img.height()), (40, 300)); + assert_eq!(img.get_pixel(20, 150).0[3], 255, "the strip is all art"); + assert_eq!(s.doc().unwrap().doc.artboards.len(), boards, "no helper artboard is left"); + // Twice the resolution, and a window that holds both art and empty page. + let img = rgba(&b64(&s.execute("document.export", &json!({"format": "png", "rect": [0.0, 40.0, 40.0, 10.0], "scale": 2})).unwrap())); + assert_eq!((img.width(), img.height()), (80, 20)); + assert_eq!((img.get_pixel(10, 10).0[3], img.get_pixel(60, 10).0[3]), (0, 255)); +} + +#[test] +fn export_rect_refuses_what_cannot_be_exported() { + let mut s = session(200.0, 400.0, 1); + s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap(); + let bad = [ + json!({"format": "png", "rect": [0, 0, 0, 10]}), + json!({"format": "png", "rect": [0, 0, -5, 10]}), + json!({"format": "png", "rect": [0, 0, 10]}), + json!({"format": "png", "rect": [0, 0, "a", 10]}), + json!({"format": "png", "rect": "20,40,40,300"}), + json!({"format": "png", "rect": [1e308, 0, 1e308, 10]}), + json!({"format": "png", "rect": [0, 0, 100000, 100000]}), + json!({"format": "png", "rect": [0, 0, 10, 10], "artboard": 0}), + json!({"format": "png", "rect": [0, 0, 10, 10], "useArtboards": true}), + json!({"format": "svg", "rect": [0, 0, 10, 10]}), + ]; + for p in bad { + assert!(s.execute("document.export", &p).is_err(), "{p}"); + } +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index b8cc7f115..0bf31cecb 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -189,6 +189,11 @@ fn every_command_with_junk_params() { fn structured_junk() { let cases: Vec<(&str, Value)> = vec![ ("select.set", json!({"ids": [0, u64::MAX, -1, "x", null, 1.5]})), + ("document.export", json!({"format": "png", "rect": [1e308, -1e308, 1e308, 1e308]})), + ("document.export", json!({"format": "png", "rect": [0, 0, 1e300, 1e300], "scale": 64})), + ("document.export", json!({"format": "tiff", "rect": [null, "x", -1, 0.0000001], "useArtboards": true})), + ("document.export", json!({"format": "bmp", "rect": {"x": 1}, "artboard": 3})), + ("document.export", json!({"format": "svg", "rect": [0, 0, 10, 10]})), ("select.add", json!({"ids": [[1], {"a": 1}]})), ("select.anchors", json!({"id": 2, "anchors": [[99, 99], [-1, 0], ["a", "b"]], "mode": "set"})), ("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000]]}, null, 5]})), diff --git a/docs/mcp.md b/docs/mcp.md index bed84a08a..bee14e10e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -318,7 +318,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `save_file` | `{path?}` | 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). | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `eps`, `dxf`, `emf`, `wmf`, `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 writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `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`); `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. | -| `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`, `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, SVGZ and EPS write one file per chosen artboard. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. `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. | +| `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`, `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, SVGZ and EPS write one file per chosen artboard. Rasters, DXF, EMF and WMF 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`, `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?}` | 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`); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | | `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). | From 7c80b286c7a419d872258f9a4eb27726732b3c1d Mon Sep 17 00:00:00 2001 From: Brian Torres Date: Fri, 16 Oct 2026 21:18:14 -0700 Subject: [PATCH 038/208] =?UTF-8?q?Fix=20#2540:=20Object=20=E2=86=92=20Exp?= =?UTF-8?q?and=20expands=20Intertwine=20groups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand had nothing for Intertwine groups. It reported success but left the group live (it only dropped the members' live shapes), and its Stroke and Fill options could not reach the members, so a second Expand found nothing to do. Ungroup takes apart plain groups only, so it did nothing either. Expand's Object option now turns every Intertwine group in the selection (inside groups too, inner ones first) into a plain group of what it draws: its objects, keeping their ids and order, then each part drawn over the others as a clip group. The group keeps its id, place, transparency and appearance, everything happens in one undo step, and the dialog enables Object for an Intertwine group. Ungroup then takes the result apart, as with an expanded blend or repeat. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/expand.rs | 15 +++++-- crates/engine/src/cmd/intertwine.rs | 38 ++++++++++++++++- crates/engine/src/cmd/live.rs | 2 +- crates/engine/src/tests_intertwine.rs | 61 +++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 863924719..da1567ce7 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -1,5 +1,5 @@ //! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills -//! (Fill) into plain art in one undo step; blends and repeats become groups of their steps and instances. Placed documents (Object) become editable copies of the +//! (Fill) into plain art in one undo step; blends, repeats and Intertwine groups become groups of their steps, of their instances and of what they draw. Placed documents (Object) become editable copies of the //! art they show, their resources joining the document. A linear or radial gradient fill becomes a //! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform //! gradient becomes a mesh shaped like the object. Create Gradient Mesh shares the sampling. @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}", + "{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}", has_selection, expand ), @@ -108,6 +108,8 @@ struct Expandable { placed: bool, /// Repeats (Object expands them into groups of their instances). repeats: bool, + /// Intertwine groups (Object expands them into groups of what they draw). + intertwines: bool, } impl Expandable { @@ -123,6 +125,7 @@ impl Expandable { e.blends |= matches!(c.kind, NodeKind::Blend { .. }); e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_)); e.repeats |= matches!(c.kind, NodeKind::Repeat(_)); + e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. }); e.stroke |= shape && !c.appearance.stroke_paint().is_none(); e.fill |= match &c.kind { NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))), @@ -134,7 +137,7 @@ impl Expandable { } fn object(&self) -> bool { - self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats + self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats || self.intertwines } } @@ -162,6 +165,12 @@ fn expand(s: &mut Session, p: &Value) -> Result { let roots = selected_roots(s)?; s.edit("Expand", |d, _| expand_placed_under(d, &roots))?; } + // Intertwine groups before what they hold: their objects, and the copies of the parts drawn + // over the others, expand below. + if object && e.intertwines { + let roots = selected_roots(s)?; + s.edit("Expand", |d, _| super::intertwine::expand_intertwines(d, &roots).map(|_| ()))?; + } // Blends first: what their steps hold (type, effects, strokes, gradients) expands below. if object && e.blends { let roots = selected_roots(s)?; diff --git a/crates/engine/src/cmd/intertwine.rs b/crates/engine/src/cmd/intertwine.rs index 566cb7b20..8947a97b5 100644 --- a/crates/engine/src/cmd/intertwine.rs +++ b/crates/engine/src/cmd/intertwine.rs @@ -5,7 +5,8 @@ //! `object.intertwine.arrange` picks overlap areas, by a click (`at`) or by an enclosed path drawn //! around them (`area`), and changes which member is drawn on top there: each member keeps the //! area where it is drawn over the others (`NodeExtras::intertwine`, see -//! `vectorcraft_doc::intertwine`). +//! `vectorcraft_doc::intertwine`). Object › Expand turns the group into a plain group of what it +//! draws ([`expand_intertwines`]), which Ungroup takes apart. //! //! An overlap area is a face of the planar arrangement of what the members paint (their fills and //! strokes, type outlined; other objects by their bounds): the part where two or more of them @@ -186,6 +187,41 @@ fn release(s: &mut Session, _: &Value) -> Result { Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } +// ---------- Expand ---------- + +/// Replace Intertwine group `id` by a plain group of the art it draws +/// ([`vectorcraft_doc::intertwine::expand`]: its objects, then each part drawn over the others as a +/// clip group), keeping everything about the group itself; the generated art gets ids of its own. +fn expand_group(d: &mut Document, id: NodeId) -> Result<()> { + let Some(mut g) = d.node(id).cloned() else { return Ok(()) }; + let NodeKind::Intertwine { children } = &g.kind else { return Ok(()) }; + g.kind = NodeKind::Group { children: vectorcraft_doc::intertwine::expand(children).into_iter().map(Arc::new).collect(), clip: false }; + super::live::fix_ids(d, &mut g); + *d.node_mut(id).ok_or(EngineError::NoNode(id))? = g; + Ok(()) +} + +/// Object › Expand: every Intertwine group in the subtrees of `roots` becomes a group of the art +/// it draws ([`expand_group`]), inner ones first so an outer one copies plain art. Returns how +/// many. A live Intertwine is expanded before it can be ungrouped: +/// . +pub(crate) fn expand_intertwines(d: &mut Document, roots: &[NodeId]) -> Result { + let mut groups = vec![]; + for r in roots { + if let Some(n) = d.node(*r) { + n.walk(&mut |c| { + if is_intertwine(c) { + groups.push(c.id); + } + }); + } + } + for g in groups.iter().rev() { + expand_group(d, *g)?; + } + Ok(groups.len()) +} + // ---------- Arrange ---------- #[derive(Clone, Copy, PartialEq)] diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index e1135c00e..0c88f7214 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -383,7 +383,7 @@ fn replace_with(d: &mut Document, id: NodeId, nodes: Vec) -> Result = ids.iter().collect(); + assert!(unique.len() == ids.len() && !ids.contains(&NodeId(0)), "the generated art has ids of its own: {ids:?}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [g]); + assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(false), "nothing live is left"); + let r = render(&s); + assert!(red(&r, 50, 30) && blue(&r, 50, 70), "it looks as it did"); + // Plain art: Ungroup takes it apart. + s.execute("object.ungroup", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(g).is_none()); + assert_eq!(s.doc().unwrap().selection.objects.len(), 3); + let r = render(&s); + assert!(red(&r, 50, 30) && blue(&r, 50, 70)); + // Undo brings the Intertwine group back. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(s.doc().unwrap().doc.node(g).unwrap().kind, NodeKind::Intertwine { .. })); +} + +/// Expand reaches an Intertwine group inside a selected group, and with every option on also +/// outlines the strokes of the rings and of the part drawn over the other one. +#[test] +fn expand_reaches_intertwine_groups_in_groups_and_outlines_their_strokes() { + let mut s = session(); + let (g, ..) = intertwined(&mut s); + arrange(&mut s, json!({"at": [50, 30]})); + let c = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap()); + select(&mut s, &[g, c]); + let outer = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.execute("object.expand", &json!({})).unwrap(); + let (mut intertwines, mut strokes) = (0, 0); + s.doc().unwrap().doc.node(outer).unwrap().walk(&mut |n| { + intertwines += usize::from(matches!(n.kind, NodeKind::Intertwine { .. })); + strokes += usize::from(!n.appearance.stroke_paint().is_none()); + }); + assert_eq!((intertwines, strokes), (0, 0)); + let r = render(&s); + assert!(red(&r, 50, 30) && blue(&r, 50, 70), "it looks as it did"); + let info = s.execute("object.expand.info", &json!({})).unwrap(); + assert_eq!((&info["object"], &info["stroke"]), (&json!(false), &json!(false)), "nothing is left to expand"); +} + #[test] fn the_arranged_areas_move_with_the_group() { let mut s = session(); From fbcb217789bece8678eef9dab8e45989b9b59501 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 20:43:42 -0700 Subject: [PATCH 039/208] Part of #2384: imported PDF text keeps its place after wide gaps between words PDF import reads a gap wider than a fifth of an em between two glyphs as a space, and a space that the line's tracking cannot place gets tracking of its own. That tracking was clamped to 1000 thousandths of an em, while type takes tracking from -1000 to 10000. A gap wider than an em plus the width of a space came in short by the excess, so the words after it sat to the left of where the file put them, and each further wide gap added to the shift. The tracking that import sets for such a space, and for the line around it, now has the same range as type's. --- crates/pdf/src/import_text.rs | 2 +- crates/pdf/src/tests_import_lines.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 04d25c0b0..04c5b3129 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -494,7 +494,7 @@ fn spacing(laid: &TextLayout, gaps: &[(usize, f64)], extra: f64, chars: usize, s tracking = if rest >= 1.0 { (extra - own.iter().map(|(_, need)| need).sum::()) / rest } else { 0.0 }; } own.sort_by_key(|(b, _)| *b); - let em = |pts: f64| (pts / size * 1000.0).clamp(-1000.0, 1000.0).round(); + let em = |pts: f64| (pts / size * 1000.0).clamp(-1000.0, 10000.0).round(); (em(tracking), own.into_iter().map(|(b, need)| (b, em(need - tracking))).collect()) } diff --git a/crates/pdf/src/tests_import_lines.rs b/crates/pdf/src/tests_import_lines.rs index fddb46221..898dcaa23 100644 --- a/crates/pdf/src/tests_import_lines.rs +++ b/crates/pdf/src/tests_import_lines.rs @@ -191,6 +191,20 @@ fn spaces_read_from_wide_gaps_keep_the_glyphs_after_them_in_place() { assert!((at(9) - f64::from(x2)).abs() < 0.05, "the third group starts where the file has it: {} vs {x2}", at(9)); } +#[test] +fn words_set_ems_apart_keep_their_place() { + // Gaps of about 1.75 and 2.6 em (#2384): each space's own tracking is more than an em. + let (x1, x2) = (48.0, 84.0); + let bytes = source_sans(&[(20.0, 50.0, 10.0, "ab"), (x1, 50.0, 10.0, "cd"), (x2, 50.0, 10.0, "ef")]); + let t = texts(&import(&bytes).unwrap()); + assert_eq!(t.len(), 1); + assert_eq!(t[0].plain_text(), "ab cd ef"); + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t[0]); + let at = |byte: usize| (t[0].xf * laid.glyphs.iter().find(|g| g.byte == byte).unwrap().origin).x; + assert!((at(3) - f64::from(x1)).abs() < 0.05, "the second word starts where the file has it: {} vs {x1}", at(3)); + assert!((at(6) - f64::from(x2)).abs() < 0.05, "the third word starts where the file has it: {} vs {x2}", at(6)); +} + /// A document of area type `text` (Source Sans 3, 10 pt) `width` wide at (20, 30). fn area_doc(text: &str, justify: Justify, width: f64, space_before: f64) -> Document { let style = CharStyle { size: 10.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; From 3c5d0e7ce436cf6ea660cc4f43ed8aa3f7e3892c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 21:16:07 -0700 Subject: [PATCH 040/208] Libraries: a plain click sets where a Shift-click range starts A plain click on an item in the library panel selected the item but did not record it as the anchor that a Shift-click ranges from. A later Shift-click then selected only the clicked item, or ranged from an older anchor set by an earlier Cmd/Ctrl-click or folder click. This applied to swatch libraries and graphic style libraries. Every click on an item or folder now goes through click_selection, as in the Swatches panel. A plain click still selects the item alone and adds and applies it. --- crates/ui-egui/src/panels/library_panel.rs | 24 ++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 00ca76fbb..0d0c1f2af 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -274,10 +274,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, lib: &K }); }); if let Some((name, folder, m)) = clicked { - // A plain click on an item adds and applies it; folders and modifier clicks select. + // A plain click on an item also adds and applies it; folders and modifier clicks only select. let only_select = folder || m.shift || m.command; let order: Vec<&str> = rows.iter().map(|r| r.name).collect(); - let sel = if only_select { click_selection(ui, &key::("anchor"), selected.clone(), &order, &name, m) } else { vec![name.clone()] }; + let sel = click_selection(ui, &key::("anchor"), selected, &order, &name, m); set_selection::(ui.ctx(), id, sel); if !only_select { K::click(app, ui, id, &name); @@ -485,6 +485,26 @@ mod tests { assert!(selection::(&ctx, "brights").is_empty()); } + #[test] + fn a_plain_click_sets_where_a_shift_click_range_starts() { + let mut app = app(); + let ctx = context(); + app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); + let (_, lib) = SwatchLibraries::get(&app, "pastels").unwrap(); + let order: Vec = SwatchLibraries::rows(&lib, "").iter().map(|r| r.name.to_string()).collect(); + click(&mut app, &ctx, &order[1], Modifiers::NONE, 0.0); + click(&mut app, &ctx, &order[4], Modifiers::SHIFT, 2.0); + assert_eq!(selection::(&ctx, "pastels"), order[1..=4], "the range starts at the plain click"); + // A plain click after a Cmd/Ctrl-click moves the anchor from the Cmd/Ctrl-clicked item. + click(&mut app, &ctx, &order[8], Modifiers::COMMAND, 4.0); + click(&mut app, &ctx, &order[2], Modifiers::NONE, 6.0); + click(&mut app, &ctx, &order[3], Modifiers::SHIFT, 8.0); + assert_eq!(selection::(&ctx, "pastels"), order[2..=3]); + let d = &app.session.doc().unwrap().doc; + assert!(d.swatch(&order[2]).is_some(), "a plain click still adds the swatch"); + assert!(d.swatch(&order[3]).is_none(), "a Shift-click only selects"); + } + #[test] fn find_filters_list_view_and_arrows_step_through_the_libraries() { let mut app = app(); From a8fbae1b1f3f1009b5e44c7019a427184e08c389 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 20:04:15 -0700 Subject: [PATCH 041/208] Graphic Styles: Shift-click selects a range of styles In the Graphic Styles panel, Shift-click and Cmd/Ctrl-click both added or removed the one style clicked. Shift-click now adds to the selection every style from the last one clicked without Shift through the clicked one, in the order the panel lists them, and Cmd/Ctrl-click still adds or removes one. A plain click applies the style and selects it alone, Alt-click still adds it on top, and the Control bar's Style picker still applies on every click. The panel uses swatches::click_selection, as the Swatches panel and the library panel do. Docs: docs/target-app-parity.md. --- crates/ui-egui/src/panels/graphic_styles.rs | 90 +++++++++++++++------ docs/target-app-parity.md | 1 + 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index dcb0b6bb9..6e488ca65 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -1,11 +1,12 @@ //! Graphic Styles panel: the document's styles as rendered thumbnails (Thumbnail View) or rows //! (Small and Large List View), shown on a square or on type. Clicking a style applies it to the -//! selection (Alt-click adds it on top), Shift/Cmd-click selects several, double-click opens -//! Graphic Style Options. Dragging a style moves it in the list, or applies it to the art it is -//! dropped on (`canvas::panel_drop`); art dragged off the canvas or the Appearance panel's -//! thumbnail dropped on the panel becomes a new style. Holding the right button on a style shows it -//! large, on the selected object when there is one. The Control bar's Style picker lists the same -//! tiles ([`picker`]). Graphic style libraries open in the library panel +//! selection (Alt-click adds it on top); Shift-click adds to the panel's selection every style from +//! the last one clicked without Shift through this one; Cmd/Ctrl-click adds or removes one; +//! double-click opens Graphic Style Options. Dragging a style moves it in the list, or applies it +//! to the art it is dropped on (`canvas::panel_drop`); art dragged off the canvas or the Appearance +//! panel's thumbnail dropped on the panel becomes a new style. Holding the right button on a style +//! shows it large, on the selected object when there is one. The Control bar's Style picker lists +//! the same tiles ([`picker`]). Graphic style libraries open in the library panel //! ([`GraphicStyleLibraries`]). use std::cell::RefCell; @@ -270,8 +271,8 @@ fn large_preview(app: &VectorcraftApp, ctx: &egui::Context, s: &Shown, i: usize) /// What a click on a style does. enum Click { - Apply(String), - Toggle(String), + /// A click with the modifiers held. + Pick(String, egui::Modifiers), Options(String), } @@ -294,8 +295,7 @@ fn clicked(ui: &Ui, resp: &Response, name: &str) -> Option { if resp.double_clicked() { Some(Click::Options(name.to_string())) } else if resp.clicked() { - let m = ui.input(|i| i.modifiers); - Some(if m.shift || m.command { Click::Toggle(name.to_string()) } else { Click::Apply(name.to_string()) }) + Some(Click::Pick(name.to_string(), ui.input(|i| i.modifiers))) } else { None } @@ -419,24 +419,19 @@ fn act(app: &mut VectorcraftApp, ui: &Ui, s: &Shown, ev: Events, picker: bool) { run(app, "graphicStyle.new", json!({ "id": id.0 })); } match ev.click { - Some(Click::Apply(name) | Click::Toggle(name)) if picker => { + Some(Click::Pick(name, _)) if picker => { app.run("graphicStyle.apply", json!({"name": name, "add": alt_held(ui)})).ok(); ui.close(); } - Some(Click::Apply(name)) => { - // With nothing selected the next object drawn takes the style. - app.run("graphicStyle.apply", json!({"name": name, "add": alt_held(ui)})).ok(); - set_pstate(ui.ctx(), "gs-sel", vec![name]); - } - Some(Click::Toggle(name)) => { - let mut sel = selected(ui.ctx(), s.d); - match sel.iter().position(|n| *n == name) { - Some(i) => { - sel.remove(i); - } - None => sel.push(name), - } + Some(Click::Pick(name, m)) => { + let order: Vec<&str> = s.d.graphic_styles.iter().map(|g| g.name.as_str()).collect(); + let sel = super::swatches::click_selection(ui, "gs-anchor", selected(ui.ctx(), s.d), &order, &name, m); set_pstate(ui.ctx(), "gs-sel", sel); + // A plain click also applies the style (with nothing selected the next object drawn + // takes it); Shift and Cmd/Ctrl clicks only select. + if !(m.shift || m.command) { + app.run("graphicStyle.apply", json!({"name": name, "add": alt_held(ui)})).ok(); + } } Some(Click::Options(name)) => { app.run("ui.graphicStyleOptions", json!({ "name": name })).ok(); @@ -938,6 +933,53 @@ mod tests { assert_eq!(d.node(rect).unwrap().extras().graphic_style, d.graphic_styles.last().map(|g| g.id)); } + #[test] + fn shift_click_selects_a_range_and_cmd_click_adds_or_removes_one_style() { + let mut app = app(); + app.session.execute("graphicStyle.new", &json!({"name": "Mine"})).unwrap(); + let n = names(&app); + assert_eq!(n.len(), 5); + let ctx = egui::Context::default(); + set_pstate(&ctx, "gs-view", View::SmallList); + frame(&ctx, |ui| show(&mut app, ui)); + // A click on row `i` (Small List View: rows of 18 points, 3 apart) with `modifiers` held, + // a second after the last one so that two clicks on a row are not a double-click. + let mut time = 0.0; + let mut click = |app: &mut VectorcraftApp, i: usize, modifiers: egui::Modifiers| { + let at = pos2(60.0, 10.0 + 21.0 * i as f32); + time += 1.0; + for pressed in [true, false] { + let button = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers }; + let raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(260.0, 600.0))), + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::PointerMoved(at), button], + time: Some(time + if pressed { 0.0 } else { 0.05 }), + ..Default::default() + }; + ctx.run_ui(raw, |ui| show(app, ui)).textures_delta.clear(); + } + selected(&ctx, &app.session.active().unwrap().doc.clone()) + }; + let linked = |app: &VectorcraftApp| app.session.selection_graphic_style().map(|(g, _)| g.name.clone()); + // A plain click applies the style and selects it alone. + assert_eq!(click(&mut app, 0, egui::Modifiers::NONE), [n[0].clone()]); + assert_eq!(linked(&app).as_ref(), Some(&n[0])); + // Shift-click selects from that style to the clicked one, and applies nothing. + assert_eq!(click(&mut app, 3, egui::Modifiers::SHIFT), n[..4]); + assert_eq!(linked(&app).as_ref(), Some(&n[0])); + // Cmd/Ctrl-click removes a selected style and adds it back. + assert_eq!(click(&mut app, 2, egui::Modifiers::COMMAND), [n[0].clone(), n[1].clone(), n[3].clone()]); + assert_eq!(click(&mut app, 2, egui::Modifiers::COMMAND), [n[0].clone(), n[1].clone(), n[3].clone(), n[2].clone()]); + // Shift-click extends from the style Cmd/Ctrl-clicked last. + click(&mut app, 0, egui::Modifiers::NONE); + click(&mut app, 2, egui::Modifiers::COMMAND); + assert_eq!(click(&mut app, 4, egui::Modifiers::SHIFT), [n[0].clone(), n[2].clone(), n[3].clone(), n[4].clone()]); + assert_eq!(linked(&app).as_ref(), Some(&n[0])); + // A plain click applies the next style. + assert_eq!(click(&mut app, 4, egui::Modifiers::NONE), [n[4].clone()]); + assert_eq!(linked(&app).as_ref(), Some(&n[4])); + } + #[test] fn the_right_button_shows_a_large_preview_and_the_picker_applies_styles() { let mut app = app(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f2043138b..6515232eb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -586,6 +586,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels. Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports. - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`. + In the Graphic Styles panel, Shift-click selects a range of styles and Ctrl/Cmd-click adds or removes one. - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included. From 34ae88292df7ee97e3453a1df0fcea6312025f29 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 20:23:11 -0700 Subject: [PATCH 042/208] Links: Shift-click selects a range of links In the Links panel, Shift-click and Cmd/Ctrl-click both added or removed the image of the one row clicked. Shift-click now selects the images of every row from the last row clicked without Shift to the clicked one, in the order the panel shows them (its Show filter and Sort order), and Cmd/Ctrl-Shift-click adds them to the selection. Cmd/Ctrl-click still adds or removes one image, a plain click selects one, and a double-click still goes to the link. When the last row clicked is not shown, or the last click was in another document, Shift-click acts as a click without Shift. A click on a row runs select.set, select.add or select.toggle, and the range is worked out as in the Layers panel's Shift-click. Docs: docs/target-app-parity.md. --- crates/ui-egui/src/panels/links.rs | 48 ++++++++++++++------ crates/ui-egui/src/panels/tests_links.rs | 57 ++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 93 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index dcf9f024c..7868c2e8e 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -1,9 +1,11 @@ //! Links panel: every image in the document, linked files with their status (missing, modified) //! and embedded images, as rows with a thumbnail, name, page and status. Clicking a row selects -//! its image; the buttons Relink, Go To, Update and Edit Original act on the selected rows; Link -//! Info shows the details of the first. The flyout adds Relink to Folder, Find in Folder (Find -//! Linked Files: `ui.findLinksInFolder`), Embed / Unembed, Placement Options, Show filters, Sort, -//! thumbnail size and Show in Folder. +//! its image, Ctrl/Cmd-click adds or removes it, and Shift-click selects the images of the rows +//! from the last row clicked without Shift to it (with Ctrl/Cmd, adds them); the buttons Relink, +//! Go To, Update and Edit Original act on the selected rows; Link Info shows the details of the +//! first. The flyout adds Relink to Folder, Find in Folder (Find Linked Files: +//! `ui.findLinksInFolder`), Embed / Unembed, Placement Options, Show filters, Sort, thumbnail size +//! and Show in Folder. //! //! Everything runs through the `links.*` commands (`links.editOriginal` and `links.reveal` are UI //! commands: they hand the file to the system). @@ -245,6 +247,31 @@ fn status_tip(r: &Value) -> String { } } +/// A click on the row of image `id` among the shown `rows`: plain selects the image alone, +/// Ctrl/Cmd adds or removes it, and Shift selects the images of the rows from the last row clicked +/// without Shift in this document to it, or with Ctrl/Cmd adds them to the selection. A +/// Shift-click while that row is not shown, or after the last click was in another document, acts +/// as a click without Shift. +fn click(app: &mut VectorcraftApp, ctx: &egui::Context, rows: &[Value], id: u64, m: egui::Modifiers) { + let Some(uid) = app.session.active().map(|st| st.uid) else { return }; + let anchor: Option<(u64, u64)> = pstate(ctx, "links-anchor"); + let at = |id: u64| rows.iter().position(|r| r["id"].as_u64() == Some(id)); + if m.shift + && let Some((_, a)) = anchor.filter(|(doc, _)| *doc == uid) + && let (Some(i), Some(j)) = (at(a), at(id)) + { + let range: Vec = rows.get(i.min(j)..=i.max(j)).unwrap_or_default().iter().filter_map(|r| r["id"].as_u64()).collect(); + app.run(if m.command { "select.add" } else { "select.set" }, json!({ "ids": range })).ok(); + return; + } + set_pstate(ctx, "links-anchor", Some((uid, id))); + if m.command { + app.run("select.toggle", json!({ "id": id })).ok(); + } else { + app.run("select.set", json!({ "ids": [id] })).ok(); + } +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let o = options(ui.ctx()); @@ -257,7 +284,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let thumbs = super::layers::ThumbSource::of(app, st, &doc); let selected = selected_images(app); let rows: &[Value] = cache.list["links"].as_array().map_or(&[], Vec::as_slice); - let mut clicked: Option<(u64, bool, bool)> = None; + let mut clicked: Option<(u64, egui::Modifiers, bool)> = None; widgets::list_box(ui, |ui| { ui.set_min_height(110.0); ui.set_width(ui.available_width()); @@ -317,21 +344,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ); let resp = resp.on_hover_text(status_tip(r)); if resp.double_clicked() { - clicked = Some((id, false, true)); + clicked = Some((id, egui::Modifiers::NONE, true)); } else if resp.clicked() { - clicked = Some((id, ui.input(|i| i.modifiers.shift || i.modifiers.command), false)); + clicked = Some((id, ui.input(|i| i.modifiers), false)); } } }); }); match clicked { Some((id, _, true)) => go_to(app, id), - Some((id, true, _)) => { - app.run("select.toggle", json!({ "id": id })).ok(); - } - Some((id, false, _)) => { - app.run("select.set", json!({ "ids": [id] })).ok(); - } + Some((id, m, false)) => click(app, ui.ctx(), rows, id, m), None => {} } if o.info { diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index f27a42b53..d81432c09 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -56,6 +56,27 @@ fn panel(app: &mut VectorcraftApp) -> String { painted_text(app, super::links::show) } +/// Click the row named `name` in the panel with `mods` held (frames at `time` and just after: a +/// second apart, two clicks are not a double-click). +fn click_row(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, mods: egui::Modifiers, time: f64) { + let frame = |app: &mut VectorcraftApp, events: Vec, time: f64| { + let events = std::iter::once(egui::Event::ModifiersChanged(mods)).chain(events).collect(); + super::tests_appearance::frame_raw(ctx, app, egui::RawInput { events, time: Some(time), ..Default::default() }, super::links::show) + }; + let at = super::tests_appearance::text_rect(&frame(app, vec![], time), name).center(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: mods }; + for (i, e) in [egui::Event::PointerMoved(at), button(true), button(false)].into_iter().enumerate() { + frame(app, vec![e], time + 0.01 * (i + 1) as f64); + } +} + +/// The selected objects' ids, in ascending order. +fn selected(app: &VectorcraftApp) -> Vec { + let mut ids: Vec = app.session.active().unwrap().selection.objects.iter().map(|id| id.0).collect(); + ids.sort_unstable(); + ids +} + #[test] fn the_panel_lists_the_images_with_link_info_for_the_selected_one() { let dir = Folder::new("panel"); @@ -75,6 +96,42 @@ fn the_panel_lists_the_images_with_link_info_for_the_selected_one() { } } +/// Shift-click selects the images of the rows from the last row clicked without Shift; Ctrl/Cmd-click +/// adds or removes one image, and Ctrl/Cmd-Shift-click adds the rows' images to the selection. +#[test] +fn shift_click_selects_a_range_of_links_and_ctrl_click_toggles_one() { + let dir = Folder::new("range"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + for (i, name) in ["a.png", "b.png", "c.png", "d.png"].into_iter().enumerate() { + let path = dir.file(name, &png(20, 20)); + app.run("file.place", json!({"path": path, "at": [50 + 80 * i, 100]})).unwrap(); + } + let list = app.run("links.list", json!({})).unwrap(); + let rows: Vec<(String, u64)> = + list["links"].as_array().unwrap().iter().map(|r| (r["name"].as_str().unwrap().to_string(), r["id"].as_u64().unwrap())).collect(); + assert_eq!(rows.len(), 4, "{list}"); + let id = |rows_at: &[usize]| { + let mut ids: Vec = rows_at.iter().map(|&i| rows[i].1).collect(); + ids.sort_unstable(); + ids + }; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + click_row(&mut app, &ctx, &rows[0].0, egui::Modifiers::NONE, 1.0); + assert_eq!(selected(&app), id(&[0])); + click_row(&mut app, &ctx, &rows[2].0, egui::Modifiers::SHIFT, 2.0); + assert_eq!(selected(&app), id(&[0, 1, 2]), "Shift-click selects the rows from the one clicked before"); + click_row(&mut app, &ctx, &rows[1].0, egui::Modifiers::COMMAND, 3.0); + assert_eq!(selected(&app), id(&[0, 2]), "Ctrl/Cmd-click removes one image"); + click_row(&mut app, &ctx, &rows[3].0, egui::Modifiers::SHIFT | egui::Modifiers::COMMAND, 4.0); + assert_eq!(selected(&app), id(&[0, 1, 2, 3]), "Ctrl/Cmd-Shift-click adds the rows from the one clicked before"); + click_row(&mut app, &ctx, &rows[0].0, egui::Modifiers::SHIFT, 5.0); + assert_eq!(selected(&app), id(&[0, 1]), "Shift-click replaces the selection with the rows"); + click_row(&mut app, &ctx, &rows[3].0, egui::Modifiers::NONE, 6.0); + assert_eq!(selected(&app), id(&[3])); +} + #[test] fn an_empty_document_shows_how_to_add_images() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6515232eb..dbde4ced8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -621,6 +621,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. - Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected. + - In the Links panel, Shift-click selects a range of links and Ctrl/Cmd-click adds or removes one. - Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`). - Import and paste, 2026-10-13: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499). - A `.ai` or `.ait` file copied in a file manager pastes as its art: one that holds a PDF pastes as a copied `.pdf` file does, and one saved as PostScript pastes as a copied `.eps` file does. From 951f7c28421f40aa8faffb7f7711f26ed9d0bb76 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 17:12:59 -0700 Subject: [PATCH 043/208] =?UTF-8?q?View:=20in=20Presentation=20Mode=20no?= =?UTF-8?q?=20key=20but=20=E2=86=90,=20=E2=86=92=20and=20Escape=20acts,=20?= =?UTF-8?q?and=20no=20context=20menu=20opens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Presentation Mode the canvas showed the active tool's cursor, a right click selected the object under the pointer and opened the context menu, and every key other than the arrows and Escape worked as in Normal Screen Mode: tool keys, Delete, Enter, Tab, F and the command shortcuts. A two-finger tap undid. With a tool that claims Escape, such as the Intertwine tool, Escape did not leave the mode. In the mode only the Escape, Left Arrow and Right Arrow keys now act, and Escape leaves the mode whatever the tool. Every other key press, the text it types and the clipboard keys are dropped before any shortcut reads them, the macOS menu bar's key equivalents included. A right click or a long touch opens no context menu, a two- or three-finger tap undoes or redoes nothing, and the pointer over the canvas is the default one. F and Shift+F no longer leave the mode; Escape does. Docs: docs/target-app-parity.md. --- crates/ui-egui/src/canvas.rs | 15 ++++- crates/ui-egui/src/menus.rs | 4 +- crates/ui-egui/src/shortcuts.rs | 73 ++++++++++++++-------- crates/ui-egui/src/tests_screenmode.rs | 85 ++++++++++++++++++++++---- docs/target-app-parity.md | 3 +- 5 files changed, 135 insertions(+), 45 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2870d1d51..d098dc98c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -236,7 +236,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let xf = Xf::new(rect, &v); let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { None }; panel_drop(app, ui, &resp, &xf); - context_menu(app, &resp, &xf); + // In Presentation Mode a right click or a long touch opens no context menu. + if app.ui.screen_mode < 3 { + context_menu(app, &resp, &xf); + } let painter = ui.painter_at(rect); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); @@ -536,7 +539,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } else { None }; - let cur = if let Some(c) = view_cursor.or_else(|| app.hover_doc.map(|p| app.session.cursor(p, mods(m, space), view_info))) { + // In Presentation Mode the pointer is the default one, with no tool cursor. + let tool_cursor = + (app.ui.screen_mode < 3).then(|| view_cursor.or_else(|| app.hover_doc.map(|p| app.session.cursor(p, mods(m, space), view_info)))); + let cur = if let Some(c) = tool_cursor.flatten() { let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tool-cursor"))); // The loaded place cursor carries the file's thumbnail. if app.session.tool_id() == "place" @@ -1210,7 +1216,10 @@ fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vec } } ui.data_mut(|d| d.insert_temp(key, taps)); - if let Some(cmd) = tap { + // In Presentation Mode a tap of two or three fingers undoes or redoes nothing. + if let Some(cmd) = tap + && app.ui.screen_mode < 3 + { crate::menus::invoke(app, cmd, serde_json::json!({})); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6b322a891..225cfb0cc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1242,8 +1242,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { app.ui.screen_mode = match p.get("mode").and_then(Value::as_u64) { Some(m) => m.min(3) as u8, - // F cycles the three screen modes; from Presentation Mode (not in the cycle) it - // goes back to Normal. + // F cycles the three screen modes. Without a mode, the command also goes from + // Presentation Mode (not in the cycle) back to Normal. None if app.ui.screen_mode >= 2 => 0, None => app.ui.screen_mode + 1, }; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index e1a844550..f258aeaf2 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -263,6 +263,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } return; } + if app.ui.screen_mode == 3 { + presentation_keys(app, ctx, escape_pair); + return; + } let typing = ctx.egui_wants_keyboard_input(); // Escape closes Edit Toolbar's drawer first (egui drops a drag from it on Escape already). if app.ui.tools_drawer && !typing && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { @@ -287,10 +291,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); } if k == Key::Escape && !busy && !claimed { - // Escape leaves Presentation Mode first: it hides the menus and panels. - if app.ui.screen_mode == 3 { - let _ = app.run("view.presentation", json!({})); - } else if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) { + if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) { let _ = app.run("object.pattern.done", json!({})); } else if app.session.active().is_some_and(|d| d.isolation.is_some()) { let _ = app.run("object.exitIsolation", json!({})); @@ -332,28 +333,6 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { if ctx.input(|i| i.key_pressed(Key::Tab)) { ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); } - // In Presentation Mode, ← and → go to the previous and the next artboard. Every arrow key press - // is removed from the input here, so no arrow key moves the selection or is sent to the tool. - if app.ui.screen_mode == 3 { - let mut steps = vec![]; - ctx.input_mut(|i| { - i.events.retain(|e| match e { - egui::Event::Key { key: Key::ArrowLeft, pressed: true, .. } => { - steps.push(false); - false - } - egui::Event::Key { key: Key::ArrowRight, pressed: true, .. } => { - steps.push(true); - false - } - egui::Event::Key { key: Key::ArrowUp | Key::ArrowDown, pressed: true, .. } => false, - _ => true, - }) - }); - for next in steps { - presentation_step(app, next); - } - } // Type tool editing: text, IME composition and editing keys go to the tool. if app.session.tool_wants_text() { type_text(app, ctx); @@ -637,6 +616,48 @@ fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context, second: boo && !crate::canvas::ruler_origin_dragging(ctx) } +/// The keys in Presentation Mode, where nothing is edited: Escape leaves the mode, and ← and → go to +/// the previous and the next artboard ([`presentation_step`]). Every other key press, the text it +/// types and the clipboard keys are removed from the input, so no shortcut, tool or field acts on +/// them. The macOS menu bar's key equivalents arrive as key presses too ([`crate::native_menu`]). +fn presentation_keys(app: &mut VectorcraftApp, ctx: &egui::Context, escape_pair: Option) { + let (mut leave, mut steps) = (false, vec![]); + ctx.input_mut(|i| { + i.events.retain(|e| match e { + egui::Event::Key { key: Key::Escape, pressed: true, .. } => { + leave = true; + false + } + egui::Event::Key { key: Key::ArrowLeft, pressed: true, .. } => { + steps.push(false); + false + } + egui::Event::Key { key: Key::ArrowRight, pressed: true, .. } => { + steps.push(true); + false + } + egui::Event::Key { pressed: true, .. } + | egui::Event::Text(_) + | egui::Event::Ime(_) + | egui::Event::Copy + | egui::Event::Cut + | egui::Event::Paste(_) => false, + _ => true, + }) + }); + for next in steps { + presentation_step(app, next); + } + if leave { + let _ = app.run("view.presentation", json!({})); + // The Escape that left the mode can start a pair, as in `handle`. + if escape_pair.is_some() && !egui::Popup::is_any_open(ctx) { + let now = ctx.input(|i| i.time); + ctx.data_mut(|d| d.insert_temp(escape_pair_id(), now)); + } + } +} + /// Go to the next artboard (`next`) or the previous one with `view.goToArtboard`, as the status bar /// navigator's Next and Previous buttons do, for the arrow keys and a click in Presentation Mode. /// On the last artboard a step forward leaves the view as it is, and so does a step back on the diff --git a/crates/ui-egui/src/tests_screenmode.rs b/crates/ui-egui/src/tests_screenmode.rs index 6c530247e..a5f5e438b 100644 --- a/crates/ui-egui/src/tests_screenmode.rs +++ b/crates/ui-egui/src/tests_screenmode.rs @@ -1,6 +1,7 @@ //! Screen modes and Presentation Mode (#472): Escape leaves Presentation Mode, F cycles the three //! screen modes, and no screen mode is saved with the preferences. In Presentation Mode, ← and → -//! and a click go between the artboards. +//! and a click go between the artboards, no other key acts, a right click opens no context menu and +//! the pointer is the default one. use serde_json::json; use vectorcraft_engine::Session; @@ -8,7 +9,7 @@ use vectorcraft_engine::Session; use crate::VectorcraftApp; /// One headless frame of the whole window with `events`. -fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) { +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> egui::FullOutput { let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), events, ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| { @@ -16,6 +17,7 @@ fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec app.ui(ui); }); out.textures_delta.clear(); + out } fn escape() -> Vec { @@ -119,8 +121,8 @@ fn arrow_keys_go_between_artboards_in_presentation_mode() { /// Next does, and the tool gets no press: a click on the rectangle with the Selection tool selects /// nothing, and with the Rectangle tool a drag draws nothing and a click pressed and released in /// one frame opens no dialog. A drag, also with the middle button or with Space held, leaves the -/// view as it is, the press that closes the context menu goes to no other artboard, and on the last -/// artboard a click leaves the view as it is. +/// view as it is, a right click opens no context menu and goes to no other artboard, and on the +/// last artboard a click leaves the view as it is. #[test] fn a_click_goes_to_the_next_artboard_in_presentation_mode() { let ctx = egui::Context::default(); @@ -136,10 +138,8 @@ fn a_click_goes_to_the_next_artboard_in_presentation_mode() { for e in [vec![right(true)], vec![right(false)], vec![]] { frame(&mut app, &ctx, e); } - assert!(egui::Popup::is_any_open(&ctx), "a right click opens the context menu"); - click(&mut app, &ctx, middle - egui::vec2(150.0, 0.0)); - assert!(!egui::Popup::is_any_open(&ctx), "a click closes it"); - assert_eq!(view(&app).0, 1, "the click that closes the context menu"); + assert!(!egui::Popup::is_any_open(&ctx), "a right click opens no context menu"); + assert_eq!(view(&app).0, 1, "a right click"); app.select_tool("rectangle"); let button = |button, pos, pressed| egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() }; let to = middle + egui::vec2(80.0, 60.0); @@ -171,8 +171,8 @@ fn a_click_goes_to_the_next_artboard_in_presentation_mode() { assert!(unchanged(&app), "nothing was selected or drawn"); } -/// In Presentation Mode a tap goes to the next artboard. A tap of two fingers undoes (Enable Touch -/// Gestures) and goes to no other artboard. +/// In Presentation Mode a tap goes to the next artboard. A tap of two fingers, which undoes in the +/// other screen modes (Enable Touch Gestures), undoes nothing and goes to no other artboard. #[test] fn a_tap_goes_to_the_next_artboard_in_presentation_mode() { let ctx = egui::Context::default(); @@ -192,16 +192,75 @@ fn a_tap_goes_to_the_next_artboard_in_presentation_mode() { tap(&mut app, 1); assert_eq!((view(&app).0, objects(&app)), (1, 1), "a tap"); tap(&mut app, 2); - assert_eq!((view(&app).0, objects(&app)), (1, 0), "two fingers undo the rectangle"); + assert_eq!((view(&app).0, objects(&app)), (1, 1), "two fingers undo nothing"); +} + +/// In Presentation Mode no key but ←, → and Escape acts: Cmd/Ctrl+A selects nothing, P and V pick +/// no tool, Delete and Backspace delete nothing, Cmd/Ctrl+Z undoes nothing, Enter opens no dialog, +/// Tab hides no panel and F keeps the screen mode. Escape still leaves the mode, also with a tool +/// that claims Escape. +#[test] +fn no_other_key_works_in_presentation_mode() { + use egui::{Key, Modifiers}; + let ctx = egui::Context::default(); + let mut app = presenting(&ctx); + // A press and release of `key` with `modifiers` held, and the text it types. + let press = |key, modifiers: Modifiers, text: Option<&str>| { + let k = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + let mut events = vec![egui::Event::ModifiersChanged(modifiers), k(true)]; + events.extend(text.map(|t| egui::Event::Text(t.into()))); + events.extend([k(false), egui::Event::ModifiersChanged(Modifiers::NONE)]); + events + }; + app.run("select.none", json!({})).unwrap(); + frame(&mut app, &ctx, press(Key::A, Modifiers::COMMAND, None)); + assert!(app.session.active().unwrap().selection.objects.is_empty(), "Cmd/Ctrl+A selected the rectangle"); + app.run("select.all", json!({})).unwrap(); + let unchanged = snapshot(&app); + let panels = (app.ui.dock, app.ui.toolbar); + for (what, events) in [ + ("Delete", press(Key::Delete, Modifiers::NONE, None)), + ("Backspace", press(Key::Backspace, Modifiers::NONE, None)), + ("Cmd/Ctrl+Z", press(Key::Z, Modifiers::COMMAND, None)), + ("Enter", press(Key::Enter, Modifiers::NONE, None)), + ("Tab", press(Key::Tab, Modifiers::NONE, None)), + ("F", press(Key::F, Modifiers::NONE, Some("f"))), + ("P", press(Key::P, Modifiers::NONE, Some("p"))), + ] { + frame(&mut app, &ctx, events); + assert!(unchanged(&app), "{what} changed the document, the selection or the undo steps"); + let state = (app.session.tool_id(), app.ui.dialog.is_none(), app.ui.screen_mode, (app.ui.dock, app.ui.toolbar)); + assert_eq!(state, ("selection", true, 3, panels), "{what}"); + } + app.select_tool("rectangle"); + frame(&mut app, &ctx, press(Key::V, Modifiers::NONE, Some("v"))); + assert_eq!(app.session.tool_id(), "rectangle", "V"); + // The Intertwine tool claims Escape in the other screen modes. + app.select_tool("intertwine"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "Escape leaves Presentation Mode"); +} + +/// In Presentation Mode the pointer over the canvas is the system's default pointer, not the +/// tool's cursor. +#[test] +fn the_pointer_is_the_default_one_in_presentation_mode() { + let ctx = egui::Context::default(); + let mut app = presenting(&ctx); + app.select_tool("rectangle"); + let middle = app.canvas_rect.unwrap().center(); + let out = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(middle)]); + assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::Default); + assert!(out.platform_output.cursor_image.is_none(), "no tool cursor bitmap"); } #[test] -fn f_cycles_the_screen_modes_and_leaves_presentation_mode() { +fn f_cycles_the_screen_modes_and_the_command_leaves_presentation_mode() { let mut app = VectorcraftApp::new(Session::new(), crate::Services::default()); let modes: Vec = (0..4).map(|_| app.run("view.screenMode", json!({})).unwrap().as_u64().unwrap()).collect(); assert_eq!(modes, [1, 2, 0, 1]); app.run("view.presentation", json!({})).unwrap(); - assert_eq!(app.run("view.screenMode", json!({})).unwrap(), json!(0), "from Presentation Mode, F goes back to Normal"); + assert_eq!(app.run("view.screenMode", json!({})).unwrap(), json!(0), "from Presentation Mode, view.screenMode goes back to Normal"); } #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index dbde4ced8..cb59e74b0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -519,8 +519,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't). - **Artboard click-to-fit (#1227):** a plain click fits the topmost editable object or group to a new artboard, including its stroke like Fit to Selected Art, and makes the new artboard active. Art and its selection stay unchanged; creation is one undo step. Handles, modified clicks, drags, Escape and tool changes retain their meaning. Tested inside an artboard and on the pasteboard (`crates/engine/tests/artboard_click.rs`); the public [CS6 Printing Guide](https://helpx.adobe.com/content/dam/help/en/indesign/pdf/CS6_PrintGuide.pdf) documents the object-click gesture. This legacy gesture adds evidence without regrading the 2026 canvas controls. - **Rotated views:** with the view rotated, Fit Artboard in Window, Fit All in Window and the status bar's artboard navigator fit the artboard or the art as the view turns it, and a Zoom tool marquee (Animated Zoom or GPU Performance off) zooms to the area dragged across. Ruler guides on no artboard turn with the view. The canvas draws the selection marquee where the tools select, Show Hidden Characters draws the marks of type partly inside the canvas, the Contextual Task Bar stays under the selection, and the Navigator's red box outlines what the window shows. -- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard, fits it and synchronizes the Artboard tool selection when that tool is active; ordinary object selection is preserved. Actual Next-button regressions cover keyboard/Properties deletion and Undo in `chrome.rs`. Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible under the outlines (#1505, fixes #1486). +- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard, fits it and synchronizes the Artboard tool selection when that tool is active; ordinary object selection is preserved. Actual Next-button regressions cover keyboard/Properties deletion and Undo in `chrome.rs`. Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, `view.screenMode` from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible under the outlines (#1505, fixes #1486). In Presentation Mode, → and ← go to the next and the previous artboard, and a click anywhere goes to the next one, as the status bar navigator's Next and Previous do (`view.goToArtboard`): on the last artboard → and a click leave the view as it is, and so does ← on the first. The arrow keys do not move the selection there, and the tool does not receive a click, so a click selects and draws nothing (behavior reference: [View artwork](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)). + In Presentation Mode no key press other than ←, → and Escape runs a command or reaches the tool (tool keys, Delete, Enter, Tab, F, Shift+F and every command shortcut, the macOS menu bar's key equivalents included), a right click or a long touch opens no context menu, a tap of two or three fingers undoes or redoes nothing, and the pointer over the canvas is the default one instead of the tool's cursor. Align to Artboard uses the active artboard, the one the Artboards panel highlights (`object.align {artboard}`). A press with a selection tool inside an artboard makes that artboard active; with the Selection tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. One selected object is aligned to the active artboard too, whatever Align To says (a lone object is never the key object). When an action plays, its Align to Artboard, All on Active Artboard, Paste in Place, Paste in Front and Paste in Back steps that name no artboard use the artboard that is active then. Choosing the Artboard tool (Shift+O, its toolbar button, or Edit Artboards in Properties or Document Setup) starts it on the active artboard: the canvas outlines that artboard, and Delete, Cut, Copy, the Transform panel and Artboard Options act on it. In the Artboards panel, a click on the active artboard's options icon, or a double-click on another row's icon, opens Artboard Options for that row's artboard, and the panel menu's Artboard Options… opens it for the active artboard (part of #1876). Both used to choose the Artboard tool instead. Opened while another tool is in use, the dialog moves the art with a new X or Y when the Artboard tool's Move Artwork with Artboard option is on. From 2e9870b1d049952e58195090b07f992c8915b25d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 19:23:19 -0700 Subject: [PATCH 044/208] Rotate View: a Space+Shift drag rotates the view from any tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drag with Space and Shift held panned the view, as a Space drag does. With Space and Shift held and Cmd up, a drag on the canvas now turns the view about the canvas center, as a Rotate View tool drag does, whatever the tool. Shift does not round the angle to 15° as it does with the Rotate View tool, and the drag keeps turning the view when Shift is let go. Space alone still pans, and Cmd+Space still zooms with or without Shift. The pointer shows the rotate cursor while Space and Shift are held without Cmd, and during the drag. The Rotate View tool also shows it now, where it showed the arrow. Docs: docs/target-app-parity.md. --- crates/ui-egui/src/canvas.rs | 108 ++++++++++++++++++++++++++++++++--- docs/target-app-parity.md | 1 + 2 files changed, 101 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d098dc98c..381148f59 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -82,9 +82,12 @@ enum Drag { held: f32, moved: bool, }, + /// A Rotate View tool drag, or a Space+Shift drag with any tool. With `snaps` (the Rotate + /// View tool), Shift rounds the angle to 15°. RotateView { start_angle: f64, start_rot: f64, + snaps: bool, }, /// A Selection tool move dragged off the canvas: the panels get the art /// ([`widgets::PanelDrag::Art`]). @@ -526,12 +529,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if resp.hovered() { let m = ui.input(|i| i.modifiers); let space = ui.input(|i| i.key_down(egui::Key::Space)); - let panning = matches!(ui.data(|d| d.get_temp::(drag_id())), Some(Drag::Pan { .. })); + let drag = ui.data(|d| d.get_temp::(drag_id())); + let panning = matches!(drag, Some(Drag::Pan { .. })); let zooming = if space { m.command } else { app.session.tool_id() == "zoom" }; + // A rotating drag keeps its cursor when Shift is let go. + let rotating = + matches!(drag, Some(Drag::RotateView { .. })) || if space { m.shift && !m.command } else { app.session.tool_id() == "rotateView" }; // The hand and the magnifier are drawn like the tool cursors below: Windows has no // system cursor for them (winit shows the four-headed move cursor for Grab). let view_cursor = if panning { Some(Cursor::HandGrab) + } else if rotating { + Some(Cursor::Rotate) } else if zooming { Some(if m.alt { Cursor::ZoomOut } else { Cursor::ZoomIn }) } else if space || app.session.tool_id() == "hand" { @@ -735,10 +744,11 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: } let tool = app.session.tool_id(); - // Held, Space is the Hand tool for the moment, and Cmd+Space the Zoom tool (with Alt, zooming - // out), whatever the tool. + // Held, Space is the Hand tool for the moment, Space+Shift is the Rotate View tool and + // Cmd+Space is the Zoom tool (with Alt, zooming out), whatever the tool. let zoom_mode = if space { m.command } else { tool == "zoom" }; - let pan_mode = if space { !m.command } else { tool == "hand" }; + let rotate_mode = if space { m.shift && !m.command } else { tool == "rotateView" }; + let pan_mode = if space { !m.command && !m.shift } else { tool == "hand" }; let middle_pan = matches!(drag, Some(Drag::Pan { middle: true, .. })); // egui counts a press a few pixels outside the canvas as on it (its interaction radius): only a // press on the canvas itself reaches the tools, else it would land at the canvas's centre. @@ -761,9 +771,9 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: if animated_zoom(&app.session.prefs) { Drag::ZoomScrub { start: p, held: 0.0, moved: false } } else { Drag::ZoomBox { start: p } } } else if pan_mode { Drag::Pan { start: p, center: v.center, middle: false } - } else if tool == "rotateView" { + } else if rotate_mode { let c = rect.center(); - Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation } + Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation, snaps: !space } } else { // Cmd with another tool drags with the selection tool used last (the session lends it). let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) }; @@ -795,11 +805,11 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: vm.center = center - d; } } - Drag::RotateView { start_angle, start_rot } => { + Drag::RotateView { start_angle, start_rot, snaps } => { let c = rect.center(); let a = (p.y - c.y).atan2(p.x - c.x) as f64; let mut deg = start_rot + (a - start_angle).to_degrees(); - if m.shift { + if snaps && m.shift { deg = (deg / 15.0).round() * 15.0; } if let Some(vm) = app.view_mut() { @@ -5117,6 +5127,88 @@ mod tests { assert!((after.center.x - (before.center.x - 30.0 / before.zoom)).abs() < 1e-6, "{before:?} → {after:?}"); } + #[test] + fn space_shift_drag_rotates_the_view_whatever_the_tool() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let c = app.canvas_rect.unwrap().center(); + let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers }; + let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + // `drag` drags from `from` to `to` with `held` down, and with Space as well when `with_space`. + let drag = |app: &mut VectorcraftApp, held: Modifiers, with_space: bool, from: Pos2, to: Pos2| { + let keys = |pressed| if with_space { vec![space(pressed, held)] } else { vec![] }; + frame(app, &ctx, [vec![Event::ModifiersChanged(held)], keys(true), vec![Event::PointerMoved(from)]].concat()); + frame(app, &ctx, vec![button(from, true, held)]); + frame(app, &ctx, vec![Event::PointerMoved(to)]); + frame(app, &ctx, vec![button(to, false, held)]); + frame(app, &ctx, [keys(false), vec![Event::ModifiersChanged(Modifiers::NONE)]].concat()); + }; + // `at(deg)` is the point 100 points from the canvas center, `deg` degrees clockwise from the right. + let at = |deg: f32| c + 100.0 * vec2(deg.to_radians().cos(), deg.to_radians().sin()); + let before = *app.view().unwrap(); + drag(&mut app, Modifiers::SHIFT, true, at(0.0), at(90.0)); + let after = *app.view().unwrap(); + assert!((after.rotation - 90.0).abs() < 1e-3, "{after:?}"); + assert_eq!((after.center, after.zoom), (before.center, before.zoom), "the view turns about its center"); + assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing"); + // Shift does not round the angle: 90° and 20° more make 110°, not 105°. + drag(&mut app, Modifiers::SHIFT, true, at(0.0), at(20.0)); + assert!((app.view().unwrap().rotation - 110.0).abs() < 1e-3, "{:?}", app.view()); + // Space alone still pans, and Cmd+Shift+Space still zooms. + let v = *app.view().unwrap(); + drag(&mut app, Modifiers::NONE, true, c, c + vec2(30.0, 0.0)); + let panned = *app.view().unwrap(); + assert_eq!((panned.rotation, panned.zoom), (v.rotation, v.zoom)); + assert_ne!(panned.center, v.center, "Space pans"); + drag(&mut app, Modifiers::COMMAND | Modifiers::SHIFT, true, c, c); + assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(v.zoom, true), "Cmd+Shift+Space click zooms in"); + assert_eq!(app.view().unwrap().rotation, v.rotation); + // The Rotate View tool's Shift still rounds to 15°. + app.select_tool("rotateView"); + app.view_mut().unwrap().rotation = 0.0; + drag(&mut app, Modifiers::SHIFT, false, at(0.0), at(20.0)); + assert_eq!(app.view().unwrap().rotation, 15.0); + drag(&mut app, Modifiers::NONE, false, at(0.0), at(20.0)); + assert!((app.view().unwrap().rotation - 35.0).abs() < 1e-3, "{:?}", app.view()); + } + + #[test] + fn the_rotate_cursor_shows_for_space_shift_and_the_rotate_view_tool() { + use egui::{CursorIcon, Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let c = app.canvas_rect.unwrap().center(); + // `shown` returns the system cursor the canvas sets over a window without the focus. On macOS + // the window paints the glyph only with the focus, and on the other desktop builds (not + // Wayland, which the tests do not set) the window sets the system cursor next to the bitmap. + let shown = |app: &mut VectorcraftApp, events: Vec| { + let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))); + let mut out = ctx.run_ui(egui::RawInput { screen_rect, focused: false, events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.platform_output.cursor_icon + }; + let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers }; + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT }; + assert_ne!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias); + assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::SHIFT), space(true, Modifiers::SHIFT)]), CursorIcon::Alias); + // Shift let go during the drag keeps the rotate cursor; after it, Space shows the hand. + shown(&mut app, vec![button(c, true)]); + assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE), Event::PointerMoved(c + vec2(20.0, 30.0))]), CursorIcon::Alias); + assert_eq!(shown(&mut app, vec![button(c + vec2(20.0, 30.0), false)]), CursorIcon::Grab); + // Cmd+Shift+Space zooms. + assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::COMMAND | Modifiers::SHIFT)]), CursorIcon::ZoomIn); + assert_ne!(shown(&mut app, vec![space(false, Modifiers::NONE), Event::ModifiersChanged(Modifiers::NONE)]), CursorIcon::Alias); + app.select_tool("rotateView"); + assert_eq!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias, "the Rotate View tool"); + } + #[test] fn space_pressed_mid_drag_moves_what_the_tool_draws() { use egui::{Event, Modifiers}; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cb59e74b0..9901ddb37 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -502,6 +502,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Duplicate Artboards, the Artboard tool's Alt-drag and Paste give the new artboard an id that no other artboard has, also when an artboard's id is the largest possible value (#1718). Deleting the copy or the original then leaves the other's guides. - **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked. - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. + A Space+Shift drag rotates the view whatever the tool, as a Rotate View tool drag does, and Shift does not round the angle to 15°. - **Swatches panel clicks:** Ctrl/Cmd-clicking New Swatch makes a spot color, and Ctrl/Cmd+Shift-clicking it makes a global process color. - **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it. In the Swatches panel, Alt-dragging a swatch onto another swatch of its kind (not a tint swatch) replaces that swatch's color, gradient or pattern, and the swatch keeps its name and place. From 5eea7e4f66dff4a99e343c305580a505ab076241 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 18:47:57 -0700 Subject: [PATCH 045/208] Appearance: the inline effect editor's text fields keep what is typed The Appearance panel's inline effect editor drew each text parameter (such as Drop Shadow's color and Revolve's edge) as a text field over a copy of the stored value made again in every frame. A character typed in one frame was gone in the next, so the field changed a value only when its new text and the Enter press arrived in the same frame, as with a paste. The field is now the panels' shared exact_text_field, which keeps the text typed while it has the focus, as the number fields beside it do. Enter or a click elsewhere sends the value with effect.setParams as one undo step, and Escape drops it. --- crates/ui-egui/src/panels/appearance.rs | 72 +++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index bb10b1ee1..a6b5a9d62 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -782,10 +782,8 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: widgets::dim_label(ui, &size.map_or_else(String::new, |(w, h)| format!("{w} × {h} px"))); } Value::String(s) => { - let mut buf = s.clone(); - let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(90.0)); - if r.lost_focus() && buf != *s { - change = Some((key.clone(), json!(buf))); + if let Some(typed) = widgets::exact_text_field(ui, ("fx", item, k, key.as_str()), s, 90.0) { + change = Some((key.clone(), json!(typed))); } } // Free Distort's corners are dragged in its dialog. @@ -1059,6 +1057,72 @@ mod tests { assert_eq!(editor_params(&e).get("angle"), Some(&json!(270))); } + /// A text field of the inline editor keeps what is typed from one frame to the next: a Drop + /// Shadow's color typed a character a frame and confirmed with Enter is set in one undo step. + /// Escape drops what is typed. + #[test] + fn the_effect_editor_keeps_text_typed_across_frames() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + set_pstate(&ctx, "ap-fx-open-None-0", true); + let mut time = 0.0; + let mut run = |app: &mut VectorcraftApp, events: Vec| { + time += 0.1; + let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out + }; + // Every text drawn, with its center. + fn texts(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let drawn = |out: &egui::FullOutput| { + let mut v = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); + v + }; + run(&mut app, vec![]); + let shown = drawn(&run(&mut app, vec![])); + let at = shown.iter().find(|(t, _)| t == "#000000").map(|(_, p)| *p); + let Some(at) = at else { panic!("no color field: {shown:?}") }; + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] { + run(&mut app, events); + } + run(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); + let undo = app.session.active().unwrap().history.undo.len(); + for ch in "#ff0000".chars() { + run(&mut app, vec![egui::Event::Text(ch.to_string())]); + } + let shown = drawn(&run(&mut app, vec![])); + assert!(shown.iter().any(|(t, _)| t == "#ff0000"), "the field shows what was typed: {shown:?}"); + run(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); + run(&mut app, vec![]); + let color = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects[0].params["color"].clone(); + assert_eq!(color(&app), json!("#ff0000")); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step"); + // Escape drops what is typed. + for events in [vec![button(true)], vec![button(false)]] { + run(&mut app, events); + } + run(&mut app, vec![egui::Event::Text("0".into())]); + let shown = drawn(&run(&mut app, vec![])); + assert!(shown.iter().any(|(t, _)| t.len() == 8 && t.starts_with('#')), "a 0 typed into the color: {shown:?}"); + run(&mut app, vec![key(egui::Key::Escape, egui::Modifiers::NONE)]); + run(&mut app, vec![]); + assert_eq!(color(&app), json!("#ff0000")); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + } + #[test] fn drops_pick_the_command() { use vectorcraft_doc::{Appearance, FillLayer}; From 1e3886c053a999c547b395351a7d5577ae51ddb2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 17:03:14 -0700 Subject: [PATCH 046/208] Document Raster Effects Settings: a file's resolution outside 1 to 2400 ppi opens in range document.rasterEffectsSettings accepts a resolution from 1 to 2,400 ppi, but a file's raster_effects_ppi opened as it was saved. With 0, -5 or 5000 in the file, object.rasterize without a ppi param failed with "ppi must be between 1 and 2400", and document.rasterEffectsSettings and document.info reported the stored value. With 0 or -5, EPS and PDF export wrote a freeform gradient as a 1 x 1 px image, and EMF export wrote every gradient as one. A saved New Document preset in the preferences kept such a value too. A file's or a saved preset's resolution outside 1 to 2,400 ppi is now read as the nearer end of that range. Documents with a resolution in range open as before. Docs: docs/mcp.md (resolution, rasterEffectsPpi). --- crates/engine/src/cmd/newdoc.rs | 7 ++++ crates/engine/src/tests_newdoc.rs | 11 ++++++ crates/engine/src/tests_rastersettings.rs | 45 +++++++++++++++++++++++ crates/format/src/lib.rs | 2 + crates/format/tests/prop_format.rs | 2 +- docs/mcp.md | 6 ++- 6 files changed, 70 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index cfec89b6a..d6c104076 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -103,6 +103,11 @@ impl PreviewMode { } } +/// A saved preset's raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite). +fn de_ppi<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + Ok(Some(f64::deserialize(d)?).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)) +} + /// Everything New Document sets: a preset (built-in, saved or recent) or `file.new`'s params. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -125,6 +130,8 @@ pub struct DocSettings { pub bleed: [f64; 4], pub background: Background, pub color_mode: ColorMode, + /// 1 to 2400 ppi (a saved preset's value outside that range is read as the nearer end). + #[serde(deserialize_with = "de_ppi")] pub raster_effects_ppi: f64, pub preview_mode: PreviewMode, } diff --git a/crates/engine/src/tests_newdoc.rs b/crates/engine/src/tests_newdoc.rs index d84859b88..380accfe5 100644 --- a/crates/engine/src/tests_newdoc.rs +++ b/crates/engine/src/tests_newdoc.rs @@ -225,6 +225,17 @@ fn saved_presets_live_in_the_preferences() { assert!(s.execute("file.newPresets.save", &json!({"name": " "})).is_err()); } +#[test] +fn a_saved_preset_with_the_resolution_out_of_range_reads_in_range() { + let mut s = Session::new(); + let saved = json!({"newDocPresets": [{"name": "Zero", "rasterEffectsPpi": 0}, {"name": "Huge", "rasterEffectsPpi": 5000}]}); + s.apply_prefs(serde_json::from_value(saved).unwrap()); + let ppi: Vec> = presets(&mut s, "Saved").iter().map(|p| p["rasterEffectsPpi"].as_f64()).collect(); + assert_eq!(ppi, [Some(1.0), Some(2400.0)]); + s.execute("file.new", &json!({"preset": "Zero"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, 1.0); +} + #[test] fn rearrange_keeps_its_grid() { let mut s = new_doc(json!({"width": 100, "height": 50, "artboards": 3})); diff --git a/crates/engine/src/tests_rastersettings.rs b/crates/engine/src/tests_rastersettings.rs index b0d20753b..616a6bf55 100644 --- a/crates/engine/src/tests_rastersettings.rs +++ b/crates/engine/src/tests_rastersettings.rs @@ -213,3 +213,48 @@ fn raster_settings_round_trip_and_old_files_load() { let back = vectorcraft_format::load(&bytes).unwrap(); assert_eq!((back.raster_effects_ppi, back.raster_effects), (72.0, RasterEffectsSettings::default())); } + +/// Save the active document with the raster effects resolution `ppi` and open the file. +fn reopen_with_ppi(s: &mut Session, ppi: f64) { + let mut d = (*s.doc().unwrap().doc).clone(); + d.raster_effects_ppi = ppi; + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d)); + s.execute("document.open", &json!({"name": "ppi.vectorcraft", "dataBase64": data})).unwrap(); +} + +#[test] +fn a_file_with_the_resolution_out_of_range_opens_with_it_in_range() { + for (saved, opened, pixels) in [(0.0, 1.0, 1), (-5.0, 1.0, 1), (5000.0, 2400.0, 334), (f64::MAX, 2400.0, 334)] { + let mut s = session(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 10, "height": 10})).unwrap()); + s.execute("paint.setStroke", &json!({"none": true, "ids": [r.0]})).unwrap(); + reopen_with_ppi(&mut s, saved); + assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, opened, "saved as {saved}"); + assert_eq!(s.execute(C, &json!({})).unwrap()["resolution"], opened); + assert_eq!(s.execute("document.info", &json!({})).unwrap()["document"]["rasterEffectsPpi"], opened); + // Rasterize without a ppi param renders the 10 pt square at it. + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let out = s.execute("object.rasterize", &json!({})).unwrap(); + assert_eq!((out["width"].as_u64(), out["height"].as_u64()), (Some(pixels), Some(pixels)), "saved as {saved}"); + } +} + +#[test] +fn a_gradient_exports_at_the_resolution_the_file_opens_with() { + for saved in [0.0, -5.0] { + let mut s = session(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 720, "height": 720})).unwrap()); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}, "ids": [r.0]})).unwrap(); + reopen_with_ppi(&mut s, saved); + // EMF writes the gradient as an image at the raster effects resolution: 720 pt at 1 ppi. + let region = vectorcraft_geom::Rect::new(0.0, 0.0, 720.0, 720.0); + let out = vectorcraft_metafile::export(&s.doc().unwrap().doc, region, vectorcraft_metafile::Kind::Emf).unwrap(); + let mut sizes = vec![]; + vectorcraft_metafile::import(&out.bytes).unwrap().document.walk(|n| { + if let NodeKind::Image(im) = &n.kind { + sizes.push((im.width, im.height)); + } + }); + assert_eq!(sizes, [(10, 10)], "saved as {saved}"); + } +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index d37b07685..713984b73 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -555,6 +555,8 @@ pub fn load_file(bytes: &[u8]) -> Result { doc.raster_effects.add_around = doc.raster_effects.add_around.clamp(0.0, vectorcraft_doc::rastersettings::MAX_ADD_AROUND); let mut depth_notes = vec![]; doc.limit_depth_noting(&mut depth_notes); + // Untrusted: the raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite). + doc.raster_effects_ppi = Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0); let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))).collect(); Ok(NativeFile { doc, depth_notes, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles }) } diff --git a/crates/format/tests/prop_format.rs b/crates/format/tests/prop_format.rs index 7ec1806ff..66b08b264 100644 --- a/crates/format/tests/prop_format.rs +++ b/crates/format/tests/prop_format.rs @@ -225,7 +225,7 @@ fn approx_equal_documents_compare_equal() { fn bug_f64_bit_exact_roundtrip() { check_native_roundtrip_exact(&rich_doc()).unwrap(); let mut d = Document::new(126.86291501015239, 214.69463130731182); - d.raster_effects_ppi = 0.1 + 0.2; + d.raster_effects_ppi = (0.1 + 0.2) * 1000.0; check_native_roundtrip_exact(&d).unwrap(); } diff --git a/docs/mcp.md b/docs/mcp.md index bee14e10e..465a4f704 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2220,7 +2220,8 @@ art). Pass any of these keys to change them in one undo step; `document.setUnits `file.new` takes everything New Document sets: `preset` (a name from `file.newPresets`), `name`, `width`/`height` (points, or lengths such as `"210 mm"`), `units`, `orientation`, `artboards` with `artboardLayout {layout, columns, -spacing, rightToLeft}`, `bleed`, `backgroundContents`, `colorMode`, `rasterEffectsPpi` and `previewMode`. +spacing, rightToLeft}`, `bleed`, `backgroundContents`, `colorMode`, `rasterEffectsPpi` (1 to 2,400; a saved preset's +value outside that range is read as the nearer end) and `previewMode`. `file.newPresets {category?}` lists the categories and presets (Recent: the last sizes used; Saved: the user's presets, kept in the preferences by `file.newPresets.save` and removed by `file.newPresets.delete`). A listed preset can be passed straight back to `file.new`. Print presets and sizes without `units` start in `unitsGeneral`; screen presets @@ -2342,7 +2343,8 @@ written unless the whole batch succeeds puts the saves and exports last, or runs ## Document Raster Effects Settings `document.rasterEffectsSettings` holds how raster effects (shadows, glows, blurs, feathers) become images when PDF -export or Expand Appearance renders them: `resolution` (ppi, also New Document's `rasterEffectsPpi`), `colorModel` +export or Expand Appearance renders them: `resolution` (1 to 2,400 ppi, also New Document's `rasterEffectsPpi`; a +file's value outside that range opens as the nearer end), `colorModel` (the document's `rgb`/`cmyk`, `grayscale` or `bitmap`), `background` (`white` makes the images opaque), `antiAlias` (off: hard edges), `clippingMask` (the white stays under the art only), `addAround` (0 to 1,000 points of room around the art; a file's value outside that range opens as the nearer end) and `preserveSpotColors` (stored). They are From 92647fa5b626f07ab63a842496d4fa81c3c66953 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:43:33 +0000 Subject: [PATCH 047/208] Quote graph data CSV labels so they round-trip through graph.setData Found while testing crates/doc/src/graph.rs:429 (GraphSpec::to_csv) and crates/engine/src/cmd/graph.rs:85 (parse_csv) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/graph.rs | 19 ++++++---- crates/engine/src/cmd/graph.rs | 66 ++++++++++++++++++++++++++++------ 2 files changed, 68 insertions(+), 17 deletions(-) diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index 37329ec62..9e1df35d0 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -430,17 +430,22 @@ impl GraphSpec { let cells = self.cells(); let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(self.series.len()); let mut out = String::from(","); - out.push_str(&(0..width).map(|s| self.series.get(s).cloned().unwrap_or_default()).collect::>().join(",")); + // Quote a label holding a separator, quote or line break (inner quotes doubled), or one with + // surrounding spaces; `always` also quotes blank and numeric labels so they stay labels. + let field = |c: &str, always: bool| { + let special = c.contains([',', '\t', '"', '\n', '\r']) || c != c.trim(); + if special || (always && (c.trim().is_empty() || c.trim().parse::().is_ok())) { + format!("\"{}\"", c.replace('"', "\"\"")) + } else { + c.to_string() + } + }; + out.push_str(&(0..width).map(|s| field(self.series.get(s).map(String::as_str).unwrap_or_default(), false)).collect::>().join(",")); out.push('\n'); let labelled = !self.categories.is_empty(); for (i, r) in cells.iter().enumerate() { if labelled { - let c = self.categories.get(i).map(String::as_str).unwrap_or_default(); - if c.trim().is_empty() || c.trim().parse::().is_ok() || c != c.trim() { - out.push_str(&format!("\"{c}\"")); - } else { - out.push_str(c); - } + out.push_str(&field(self.categories.get(i).map(String::as_str).unwrap_or_default(), true)); out.push(','); } out.push_str(&r.iter().map(|v| v.map(|v| v.to_string()).unwrap_or_default()).collect::>().join(",")); diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 8ade093e8..d326e3dba 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -82,20 +82,42 @@ impl Cell { } } +/// Split `s` at each `sep` outside straight quotes (a `""` inside quotes toggles twice, so stays inside). +fn split_unquoted(s: &str, sep: char) -> Vec<&str> { + let mut parts = vec![]; + let mut quoted = false; + let mut start = 0; + for (i, ch) in s.char_indices() { + if ch == '"' { + quoted = !quoted; + } else if ch == sep && !quoted { + parts.push(s.get(start..i).unwrap_or_default()); + start = i + ch.len_utf8(); + } + } + parts.push(s.get(start..).unwrap_or_default()); + parts +} + +/// One CSV cell: a cell wholly in quotes keeps its inner text (`""` = `"`), others are trimmed. +fn cell(c: &str) -> Cell { + let c = c.trim(); + match c.strip_prefix('"').and_then(|c| c.strip_suffix('"')) { + Some(inner) => Cell { text: inner.replace("\"\"", "\""), quoted: true }, + None => Cell { text: c.trim_matches('"').to_string(), quoted: false }, + } +} + fn parse_csv(csv: &str) -> (Vec, Vec, Vec>>) { - let rows: Vec> = csv - .lines() + // Records end at line breaks outside quotes; a record is tab-separated if it has a tab outside quotes. + let rows: Vec> = split_unquoted(csv, '\n') + .into_iter() .map(|l| l.trim_end_matches('\r')) .filter(|l| !l.trim().is_empty()) .map(|l| { - let sep = if l.contains('\t') { '\t' } else { ',' }; - l.split(sep) - .map(|c| { - let c = c.trim(); - let quoted = c.len() >= 2 && c.starts_with('"') && c.ends_with('"'); - Cell { text: c.trim_matches('"').to_string(), quoted } - }) - .collect() + let tabs = split_unquoted(l, '\t'); + let parts = if tabs.len() > 1 { tabs } else { split_unquoted(l, ',') }; + parts.into_iter().map(cell).collect() }) .collect(); let Some(first) = rows.first() else { return (vec![], vec![], vec![]) }; @@ -2370,6 +2392,30 @@ mod tests { assert_eq!(cells, g.cells(), "{csv}"); } + #[test] + fn labels_with_commas_quotes_and_newlines_round_trip_through_csv() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let series = json!(["Revenue, USD", "Say \"hi\"", "a\tb"]); + let categories = json!(["Doe, Jane", "2024", " padded ", "two\nlines"]); + let rows = json!([[1, 2, 3], [4, null, 6], [7, 8, 9], [10, 11, 12]]); + let r = s + .execute( + "graph.create", + &json!({"type": "column", "x": 20, "y": 20, "width": 300, "height": 200, "series": series, "categories": categories, "rows": rows}), + ) + .unwrap(); + let id = r["id"].as_u64().unwrap(); + let before = s.execute("graph.setData", &json!({"id": id})).unwrap(); + assert_eq!(before["series"], series); + assert_eq!(before["categories"], categories); + s.execute("graph.setData", &json!({"id": id, "csv": before["csv"]})).unwrap(); + let after = s.execute("graph.setData", &json!({"id": id})).unwrap(); + assert_eq!(after["series"], before["series"], "{}", before["csv"]); + assert_eq!(after["categories"], before["categories"], "{}", before["csv"]); + assert_eq!(after["rows"], before["rows"], "{}", before["csv"]); + } + #[test] fn empty_cells_are_blank_and_quoted_numbers_are_labels() { // Quoted years label the rows; empty and non-numeric cells are blank values, not zeros. From ee6158360607a1d7424c926fc38f61e0bbcd6d17 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 15:42:09 -0700 Subject: [PATCH 048/208] Artboards: commands given an index that names no artboard fail instead of editing another one artboard.setProps and artboard.fitToSelection read an `index` that is negative, fractional, a string or null as 0 and edited the first artboard, and artboard.setProps edited the first artboard when the call named no `index`. artboard.duplicate, artboard.copy and artboard.cut took the Artboard tool's artboard for such an `index`. artboard.reorder failed for it with "missing index". These commands now read `index` as artboard.delete, artboard.move and artboard.fitToArt do, and fail with "no such artboard" when it names no artboard; the document and the clipboard stay as they were. artboard.setProps without `index` fails with "missing index". Without `index`, artboard.fitToSelection still fits the first artboard and the copy commands still take the Artboard tool's artboard. Docs: docs/mcp.md. --- crates/engine/src/cmd/layer.rs | 4 ++-- crates/engine/src/cmd/panelcmds.rs | 23 +++++++++++++++++------ crates/engine/src/tests_menucmds.rs | 25 +++++++++++++++++++++++++ crates/engine/src/tests_panelcmds.rs | 21 +++++++++++++++++++++ docs/mcp.md | 4 ++++ 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 9d398d6a1..8ff970171 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -834,7 +834,7 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result { } fn artboard_set(s: &mut Session, p: &Value) -> Result { - let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; + let i = super::panelcmds::artboard_index(&s.doc()?.doc, p, "artboard.setProps")?; let was = s.doc()?.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; let now = rect_from(p, was); let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6; @@ -933,8 +933,8 @@ fn artboard_fit_art(s: &mut Session, p: &Value) -> Result { } fn artboard_fit_sel(s: &mut Session, p: &Value) -> Result { - let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; let st = s.doc()?; + let i = if p.get("index").is_none() { 0 } else { super::panelcmds::artboard_index(&st.doc, p, "artboard.fitToSelection")? }; let b = st.doc.bounds_of(&st.selection.objects, true).ok_or_else(|| EngineError::Other("no selection bounds".into()))?; s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()})) } diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 968444277..9b55ff8b1 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { fn artboard_reorder(s: &mut Session, p: &Value) -> Result { const C: &str = "artboard.reorder"; - let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing index"))? as usize; + let i = artboard_index(&s.doc()?.doc, p, C)?; let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize; s.edit("Reorder Artboards", |d, _| { if i >= d.artboards.len() { @@ -70,10 +70,21 @@ fn artboard_reorder(s: &mut Session, p: &Value) -> Result { /// The artboard (`index`) and whether its art comes along (`art`) for the artboard copy /// commands: by default the Artboard tool's artboard and its Move/Copy Artwork with Artboard option. -fn board_and_art(s: &Session, p: &Value) -> (usize, bool) { +/// An `index` that names no artboard is an error. +fn board_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(usize, bool)> { let t = s.tool_options_of("artboard"); - let i = p.get("index").and_then(Value::as_u64).or_else(|| t["active"].as_u64()).unwrap_or(0) as usize; - (i, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true))) + let i = match p.get("index") { + Some(_) => artboard_index(&s.doc()?.doc, p, cmd)?, + None => t["active"].as_u64().unwrap_or(0) as usize, + }; + Ok((i, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true)))) +} + +/// The one artboard `cmd` acts on: `index`, an artboard of `d`, with the errors of +/// [`artboard_indices`] (`missing index`, `no such artboard`). +pub(crate) fn artboard_index(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result { + let i = p.get("index").ok_or_else(|| bad(cmd, "missing index"))?; + artboard_indices(d, &json!({ "index": i }), cmd)?.into_iter().next().ok_or_else(|| EngineError::Other("no such artboard".into())) } /// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden @@ -106,7 +117,7 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec } fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { - let (i, art) = board_and_art(s, p); + let (i, art) = board_and_art(s, p, "artboard.duplicate")?; let art = artboard_art(s, i, art)?; let sc = Scaling { patterns: super::object::transform_patterns(s, p, &art)?, ..Scaling::default() }; let (index, ids) = s.edit("Duplicate Artboard", |d, _| { @@ -119,7 +130,7 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { /// Artboard Copy / Cut: the artboard and its art onto the clipboard (pasting brings both); a cut /// then deletes them. pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result { - let (i, art) = board_and_art(s, p); + let (i, art) = board_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?; let art = artboard_art(s, i, art)?; let st = s.doc()?; if cut && st.doc.artboards.len() <= 1 { diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 22642ec0c..e05e5db99 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -736,6 +736,31 @@ fn fit_to_artwork_bounds_fits_each_artboard_to_its_own_art() { assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 1050.0, 280.0), was[1], was[2]], "the first artboard by default"); } +/// Artboard Options and Fit to Selected Art fail with `no such artboard` on an `index` that names +/// no artboard (past the last, negative, fractional, a string or null), and the artboards stay as +/// they were. Artboard Options needs an `index`; Fit to Selected Art without one fits the first +/// artboard. +#[test] +fn artboard_options_and_fit_to_selected_art_refuse_an_index_that_names_no_artboard() { + let mut s = session(); + s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap(); + rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("select.all", &json!({})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::>(); + let (was, n) = (boards(&s), undo_len(&s)); + for index in [json!(99), json!(-1), json!(1.5), json!("1"), Value::Null] { + for (cmd, p) in [("artboard.setProps", json!({"index": index, "width": 300})), ("artboard.fitToSelection", json!({"index": index}))] { + assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}"); + } + } + let e = s.execute("artboard.setProps", &json!({"width": 300})).unwrap_err().to_string(); + assert_eq!(e, "invalid parameters for `artboard.setProps`: missing index"); + assert_eq!((boards(&s), undo_len(&s)), (was.clone(), n), "the artboards and the undo steps as they were"); + s.execute("artboard.fitToSelection", &json!({})).unwrap(); + assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 150.0, 150.0), was[1]], "the first artboard by default"); +} + // ---------- Edit Colors ---------- #[test] diff --git a/crates/engine/src/tests_panelcmds.rs b/crates/engine/src/tests_panelcmds.rs index 8c62ffbd1..47066421a 100644 --- a/crates/engine/src/tests_panelcmds.rs +++ b/crates/engine/src/tests_panelcmds.rs @@ -130,6 +130,27 @@ fn duplicate_artboard_copies_its_art() { assert_eq!(ids(&s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"]).len(), 1); } +/// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on an +/// `index` that names no artboard (past the last, negative, fractional, a string or null), and the +/// artboards, the art and the clipboard stay as they were. +#[test] +fn artboard_copies_refuse_an_index_that_names_no_artboard() { + let mut s = session(); + rect(&mut s); + s.execute("artboard.new", &json!({})).unwrap(); + s.set_tool_options(Some("artboard"), json!({"active": 1}).as_object().unwrap()); + let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len()); + for index in [json!(99), json!(-1), json!(1.5), json!("1"), serde_json::Value::Null] { + for cmd in ["artboard.duplicate", "artboard.copy", "artboard.cut", "artboard.reorder"] { + let p = if cmd == "artboard.reorder" { json!({"index": index, "to": 0}) } else { json!({"index": index}) }; + assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}"); + } + } + let st = s.doc().unwrap(); + assert_eq!((boards(&s), st.doc.layers[0].children().unwrap().len(), st.history.undo.len()), (was, 1, n)); + assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboard.is_none(), "nothing copied"); +} + /// With the Artboard tool, Copy and Paste copy its artboard with the art on it (#446): Paste places /// it right of the last artboard, Paste in Place where it was, also in another document; each paste /// is one undo step, and the tool takes the pasted artboard. diff --git a/docs/mcp.md b/docs/mcp.md index 465a4f704..f77f4b2b7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2287,6 +2287,10 @@ pastes only the art), in this document or another; the result's `artboard` is th does the same in one step → `{index, ids}`, and `artboard.move {copy: true, moveArt: true}` is the Artboard tool's Alt-drag. These two commands also move the pattern tiles of the copies when `patterns` is true (default: prefs `transformPatternTiles`); `edit.paste` leaves the tiles of a pasted artboard's art where they were. +`artboard.copy`, `artboard.cut` and `artboard.duplicate` take the Artboard tool's artboard when the call names no +`index`. Every artboard command that takes an `index` fails with `no such artboard` when the `index` is not the +0-based index of an artboard (past the last one, negative, fractional, a string or null), and the document and the +clipboard stay as they were. Scale Artwork with Artboard (#602): `artboard.setProps {index, x?, y?, width?, height?, scaleArt: true}` resizes an artboard and scales the art fully inside it, and its artboard guides, from the old rectangle onto the new one, each side From 27230233ccabf8cec2149d66cb0f5dab6baa40f3 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:09:18 +0200 Subject: [PATCH 049/208] Effects: a Transform effect on a group paints each copy with its own gradient (#2451) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/group.rs | 28 ++++++++++++++++++++++++ crates/effects/src/reshape.rs | 40 +++++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 69425b907..2211a4364 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -491,4 +491,32 @@ mod tests { assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}"); assert_eq!(start(&members[0]), 0.0, "the square's gradient stays where it was placed"); } + + #[test] + fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; + let paints = [Paint::Gradient(Box::new(ramp.clone())), Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))]; + for (k, paint) in paints.into_iter().enumerate() { + let square = + Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0))); + let mut g = Node::group(NodeId(10), vec![square]); + let params = serde_json::json!({"moveH": 30, "copies": 2}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(&g).unwrap(); + let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own"); + assert_eq!(copies.len(), 3); + for (i, c) in copies.iter().enumerate() { + // Each copy's own box: an unplaced gradient fits it, a placed one moved with it. + assert!((c.geometric_bounds().unwrap().x0 - 30.0 * i as f64).abs() < 1e-6); + match c.appearance.fill_paint() { + Paint::Gradient(g) if k == 0 => assert!(g.geom.is_none(), "copy {i} fits its own box"), + Paint::Gradient(g) => assert!((g.geom.unwrap().start.x - 30.0 * i as f64).abs() < 1e-6, "copy {i}"), + p => panic!("a gradient: {p:?}"), + } + } + } + } } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index a6e2926ac..ebfd22464 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -268,13 +268,21 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) { for e in fx { let Some(bounds) = art.geometric_bounds() else { break }; let params = merged_params(&e.id, &e.params); + let transform = (e.id == "distort.transform").then(|| crate::distort::transform_map(bounds, ¶ms, seed)); for_each_leaf(art, &mut |leaf| { let Some((path, rule)) = leaf_geometry(leaf) else { return }; + // A gradient paints each copy by itself, not the one piece the copies make together. + if let Some((m, copies)) = transform + && copies > 0 + && (leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient()) + { + copy_with_gradients(leaf, &path, m, copies); + return; + } let out = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }); set_geometry(leaf, out, rule); }); - if e.id == "distort.transform" { - let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed); + if let Some((m, copies)) = transform { transform_placed(art, m, copies); } } @@ -283,6 +291,34 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) { } } +/// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies` +/// more, each mapped once more by `m` than the last, as a Transform effect copies an object. Each +/// copy is an object of its own, so its gradients fit it (a placed gradient moves with it) rather +/// than spanning every copy. The copies stop where [`crate::distort::transform_within`]'s would. +fn copy_with_gradients(leaf: &mut Node, path: &PathData, m: Affine, copies: usize) { + let each = path.anchor_count(); + let mut anchors = each; + let mut placements: Vec> = vec![Arc::new(leaf.clone())]; + let mut xf = Affine::IDENTITY; + for _ in 0..copies { + anchors = anchors.saturating_add(each); + if anchors > crate::distort::MAX_COPY_ANCHORS { + break; + } + xf *= m; + let mut copy = leaf.clone(); + copy.transform(xf, vectorcraft_doc::Scaling::default()); + if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) { + break; + } + placements.push(Arc::new(copy)); + } + // The copies become nodes of their own under a group with the object's id and name. + let mut g = Node::group(leaf.id, placements); + g.name = leaf.name.clone(); + *leaf = g; +} + /// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in /// order. A group inside one of them is part of it. fn type_outline_bounds(n: &Node, out: &mut Vec>) { From a6012d86e85329d0ed8ca35c45505c8c528ab229 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:13:56 +0200 Subject: [PATCH 050/208] Effects: a path with its own Transform effect paints each copy with its own gradient (#2451) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 2 +- crates/effects/src/reshape.rs | 23 ++++++++++++++++++++++- crates/render/src/fx.rs | 4 +++- crates/render/src/tests_objectfx.rs | 21 +++++++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 89ba2d491..3f10343db 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -69,7 +69,7 @@ pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RasterFx, outset, raster_effects}; -pub use reshape::{expand_outlined, has_item_geometry, needs_outline, outline_art, outline_text, reshape}; +pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, needs_outline, outline_art, outline_text, reshape}; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid, diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index ebfd22464..fa59cae55 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -32,6 +32,17 @@ pub fn needs_outline(n: &Node) -> bool { ) } +/// Is `n` a path or compound path with a visible Transform effect that makes copies and a gradient +/// in a fill or stroke? Such an object is drawn through its art ([`outline_art`]) so that each copy +/// paints its gradient by itself ([`copy_with_gradients`]), as a group's copies do. +pub fn has_gradient_copies(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }) + && (n.appearance.has_unplaced_gradient() || n.appearance.has_placed_gradient()) + && geometry_effects(n) + .iter() + .any(|e| e.id == "distort.transform" && crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).1 > 0) +} + /// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a /// visible geometry effect? Such an effect reshapes the whole outline that its fill or stroke /// paints ([`outline_text`], which leaves inline graphics out). @@ -127,6 +138,16 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option { a.transform(*xf, false); Node::group(n.id, vec![Arc::new(a)]) } + // The object as the one piece the copies of its Transform effect are made from. + _ if has_gradient_copies(n) => { + let mut one = n.clone(); + one.opacity = 1.0; + one.blend = Default::default(); + one.isolate = false; + one.extras_mut().mask = None; + one.appearance.effects.clear(); + Node::group(n.id, vec![Arc::new(one)]) + } _ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)), _ => return None, }; @@ -171,7 +192,7 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) { /// a geometry effect on a fill or stroke of its own ([`has_item_geometry`]), or has no art (a /// symbol instance without `symbol_art`). pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option { - if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n) { + if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_gradient_copies(n)) { return None; } as_art(n, symbol_art) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 23352a280..f5281fb47 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -68,11 +68,13 @@ pub(crate) fn has_fx(n: &Node) -> bool { /// Does `n` carry visible object-level effects that only apply through its art (type, images, /// symbol instances, live objects; Crop Marks and colour adjustments on anything), is it type with -/// a geometry effect on a fill or stroke of its own ([`effects::has_item_geometry`]), or is it a +/// a geometry effect on a fill or stroke of its own ([`effects::has_item_geometry`]), is it a path +/// whose Transform effect copies it with a gradient ([`effects::has_gradient_copies`]), or is it a /// group or layer with an appearance of its own (see [`Renderer::draw_object_fx`])? pub(crate) fn has_object_fx(n: &Node) -> bool { (effects::needs_outline(n) && (visible(&n.appearance.effects) || n.projection().is_some())) || effects::has_item_geometry(n) + || effects::has_gradient_copies(n) || effects::has_container_appearance(n) || effects::has_crop_marks(n) || effects::has_adjustment(n) diff --git a/crates/render/src/tests_objectfx.rs b/crates/render/src/tests_objectfx.rs index 7a9add652..f4c72553f 100644 --- a/crates/render/src/tests_objectfx.rs +++ b/crates/render/src/tests_objectfx.rs @@ -697,3 +697,24 @@ fn a_strokes_own_glow_paints_around_that_stroke_only() { let worst = img.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap(); assert!(worst <= 4, "max channel difference {worst}"); } + +#[test] +fn a_path_with_its_own_transform_paints_each_copy_with_its_own_gradient() { + use vectorcraft_color::GradientGeom; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + let geom = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(20.0, 50.0), aspect: 1.0, focal: None }; + // Fitted to the box on each render, and placed on the object. + for paint in [ramp.clone(), GradientPaint { geom: Some(geom), ..ramp }] { + let square = shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 40.0, 20.0, 60.0)); + let mut n = Node::path(NodeId(0), square, Appearance::basic(Paint::Gradient(Box::new(paint)), Paint::None, 0.0)); + n.appearance.effects = vec![fx("distort.transform", json!({"moveH": 40, "copies": 1}))]; + let img = render(&doc_with(Document::new(100.0, 100.0), n)); + let at = |x: usize| img.pixels.chunks(4).nth(50 * 100 + x).map(|p| (p[0], p[2])).unwrap_or_default(); + // The left edge of each copy is red, its right edge blue. + for (left, right) in [(1, 18), (41, 58)] { + assert!(at(left).0 > 200 && at(left).1 < 60, "{:?} at {left}", at(left)); + assert!(at(right).1 > 200 && at(right).0 < 60, "{:?} at {right}", at(right)); + } + } +} From b7018a337085288bc930f14f9a2419e58e6a7605 Mon Sep 17 00:00:00 2001 From: andeecollard Date: Fri, 16 Oct 2026 11:58:50 +0000 Subject: [PATCH 051/208] docs: quick Image Trace presets trace at once; the Image Trace button opens the controls The UI parity row (and G12 in gaps.md, M12 in roadmap.md) said quick Image Trace presets reveal the editable controls. Since baadbe2c6 a quick trace leaves the panel as it was (test a_quick_trace_leaves_the_panel_as_it_was) and the Image Trace button in the Control bar or Properties opens the controls. That matches the public documentation: choosing a preset traces the image with it straight away, and the panel is opened with the Image Trace button in the Control panel or the Properties panel's Image Trace section (https://helpx.adobe.com/illustrator/using/image-trace-results-optimization.html). Reworded the three rows, cited the URL, added revision-history entries and status-line notes. Wording only; scores, statuses and hours unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB --- docs/gaps.md | 5 ++++- docs/roadmap.md | 5 +++-- docs/ui-parity.md | 5 +++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 8cb39b77f..65d067ccf 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make @@ -193,6 +193,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G12 Preferences and panels that don't act yet +- **Done:** quick Image Trace presets trace at once and leave the panel as it was; the Image Trace button (Control bar, Properties) opens the controls with Threshold and Advanced (Paths, Corners, Noise), as the public documentation describes (). Other context-specific Properties gaps remain. + - **Missing:** ~14 Preferences options stored but not acted on (#394: Open - **Done:** quick Image Trace presets reveal Threshold and Advanced controls (Paths, Corners, Noise); Properties can reopen them. Other context-specific Properties gaps remain. - **Missing:** ~15 Preferences options stored but not acted on (#394: Highlight Alternate Glyphs, Open @@ -331,6 +333,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Correction to G12: quick Image Trace presets trace at once and leave the panel as it was; the Image Trace button in the Control bar or Properties opens the controls (). Wording only; no entry opened or closed, estimates unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). | | 2026-10-16 | minor | G1: Smart Guides line extension for a dragged end of a straight segment, at any angle (#2120); estimate unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 1fdde8d36..6e0fe3b9a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; M2: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; legacy `.ai` of version 7 saved, #868; estimates unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (correction: M12's Image Trace quick presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; M2: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; legacy `.ai` of version 7 saved, #868; estimates unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and [target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5 @@ -64,7 +64,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) | 🟡 | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; broader scripting and batch work remains | 18–27 | -| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; quick presets reveal adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 | +| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; quick presets trace at once and the Image Trace button opens the adjustment controls; tiny fitted contours retain islands and holes; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | | M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 | @@ -84,6 +84,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14 | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Correction to M12: quick Image Trace presets trace at once; the Image Trace button opens the adjustment controls (). Wording only; status unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-15 | minor | Image Trace retains nonzero positioned boundaries when a fixed-end returning piece has fewer than two fitted cubics. Source-generated tests check survival, winding, shared-edge reversal and Noise controls; see `image-trace-quality.md`. Scores and hours unchanged. | | 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index dfbd47ff0..9e17ae045 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to, @@ -200,7 +200,7 @@ panel-menu commands such as Break Link to Symbol), so they can be given shortcut | Floating groups stack into sets; sets dock as extra dock columns (a multi-column dock); groups drop anywhere in a set or column, or onto its tabs; a column taller than the window scrolls | done | `window.panel.float {below, above}`, `window.panel.dock {column}` | | Panels outside the app window (another monitor) | missing | single OS window | | Edit Toolbar (•••): the All Tools drawer opens beside the toolbar by its button (Escape or a click outside closes it), dims the tools already in the toolbar; while it is open, tools drag from it onto the toolbar (between two tools, or onto one to join its group), from the toolbar into it, and within the toolbar; each toolbar keeps its edits | done | #1018; `window.editToolbar`, `toolbar.addTool`/`removeTool`/`reset`; the drawer's Reset (Illustrator has it in a drawer menu); dragging a toolbar button moves the tool it shows, not its whole group; New/Manage Toolbars missing; tool flyouts are as wide as their longest translated name and shortcut need (#1616) | -| Quick Image Trace presets reveal editable controls; Properties reopens them | done | Threshold, Paths, Corners and Noise; `panels/image_trace.rs` headless interaction tests. No algorithm parity claim. | +| Quick Image Trace presets trace at once and leave the panel as it was; the Image Trace button (Control bar, Properties) opens the editable controls | done | Per the public documentation, choosing a preset traces the image with it straight away, and the panel is opened with the Image Trace button in the Control panel or in Properties' Image Trace section (). The controls show Threshold, Paths, Corners and Noise with Advanced open; `panels/image_trace.rs` headless tests (`a_quick_trace_leaves_the_panel_as_it_was`, `sketch_quick_trace_reveals_all_adjustments_and_keeps_the_source` for the button's route). No algorithm parity claim. | | Toolbar without group labels by default, one or two columns; All Tools drawer by flyout group with shortcuts, its menu (Reset, Basic, Advanced) and Show section | done | #1751; a preference brings the labels back; `toolbar.show` sets the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls | | Actions panel: a clicked action stays selected for Play Current Selection and Delete Selection | partial | #1853 (part of #1629); editing actions and action shortcuts remain | | Properties panel per context (every selection type's Quick Actions) | partial | | @@ -234,6 +234,7 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, # | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Correction: quick Image Trace presets trace at once and leave the panel as it was (the 2026-10-14 row below said a quick preset reveals it; the later change in `baadbe2c6` made the button the way in); the Image Trace button in the Control bar or Properties opens the controls, as the public documentation describes. Wording only; scores and hours unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-16 | minor | Anchor Point tool: a click on one handle end retracts that handle only; the anchor becomes a corner and keeps the other handle (#2267). Handle drags start past the same 2 px as anchor drags. Scores and hours unchanged. | | 2026-10-16 | minor | Smart Guides: the dragged end of a straight segment snaps to the segment's extension at any angle, with the guide from its other end (#2120); scores and hours unchanged | From b28e8490c23a358bbefafe772544698498c4e7e0 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 19:56:05 +0200 Subject: [PATCH 052/208] Effects: skip type tests when built without craft-fonts (#2476) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/bake.rs | 36 ++++++++++++++++++++++++++++++++++++ crates/effects/src/group.rs | 21 +++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 6697f5b65..e0165c2ef 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -572,6 +572,15 @@ mod tests { use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject}; use vectorcraft_geom::{Point, Rect, shapes}; + /// Type tests need glyph outlines, which only the optional craft-fonts build input provides. + fn skip_without_craft_fonts() -> bool { + let skip = vectorcraft_text::CRAFT_FONTS.is_empty(); + if skip { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + } + skip + } + fn fx(id: &str, params: serde_json::Value) -> Effect { Effect { id: id.into(), params, visible: true } } @@ -647,6 +656,9 @@ mod tests { #[test] fn type_moved_or_copied_by_a_groups_transform_effect_stays_type() { + if skip_without_craft_fonts() { + return; + } for (params, origins, square) in [ (json!({"moveH": 30}), vec![(50.0, 60.0)], Rect::new(30.0, 0.0, 40.0, 10.0)), (json!({"moveH": 200, "copies": 1}), vec![(20.0, 60.0), (220.0, 60.0)], Rect::new(0.0, 0.0, 210.0, 10.0)), @@ -678,6 +690,9 @@ mod tests { #[test] fn hidden_type_in_a_group_that_keeps_its_type_stays_hidden() { + if skip_without_craft_fonts() { + return; + } let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]); let mut hidden = type_node(55); hidden.visible = false; @@ -689,6 +704,9 @@ mod tests { #[test] fn type_outlined_next_to_type_that_stays_type_keeps_its_look() { + if skip_without_craft_fonts() { + return; + } let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]); let mut shadowed = type_node(55); shadowed.opacity = 0.5; @@ -711,6 +729,9 @@ mod tests { #[test] fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() { + if skip_without_craft_fonts() { + return; + } let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]); // The clipping path is a group with a fill of its own, holding type with an effect of its own. let mut moved = type_node(60); @@ -729,6 +750,9 @@ mod tests { #[test] fn type_with_its_own_transform_effect_copies_stays_type() { + if skip_without_craft_fonts() { + return; + } let mut n = type_node(50); n.opacity = 0.5; n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 2})), fx("stylize.dropShadow", json!({}))]; @@ -775,6 +799,9 @@ mod tests { #[test] fn type_whose_copies_paint_over_each_other_is_still_outlined() { + if skip_without_craft_fonts() { + return; + } let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))]; assert_eq!(type_origins(&bake_document(&doc_with(type_group(moves.clone()))).unwrap()).len(), 2); // A 20 pt stroke on the characters reaches the copy below. @@ -790,6 +817,9 @@ mod tests { #[test] fn type_with_a_look_of_its_own_in_a_moved_group_is_still_outlined() { + if skip_without_craft_fonts() { + return; + } let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))]; let mut translucent = type_node(50); translucent.opacity = 0.5; @@ -862,6 +892,9 @@ mod tests { #[test] fn type_whose_copies_hold_too_much_is_still_outlined() { + if skip_without_craft_fonts() { + return; + } let spaced = |spaces: usize| { let mut n = type_node(50); if let NodeKind::Text(t) = &mut n.kind { @@ -899,6 +932,9 @@ mod tests { #[test] fn type_in_a_pathfinder_group_inside_a_moved_group_is_still_outlined() { + if skip_without_craft_fonts() { + return; + } let mut inner = type_group(vec![fx("pathfinder.add", json!({}))]); inner.id = NodeId(53); let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]); diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 78d51dca8..69425b907 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -293,6 +293,15 @@ mod tests { use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject}; use vectorcraft_geom::{Point, Rect, shapes}; + /// Type tests need glyph outlines, which only the optional craft-fonts build input provides. + fn skip_without_craft_fonts() -> bool { + let skip = vectorcraft_text::CRAFT_FONTS.is_empty(); + if skip { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + } + skip + } + fn sq(id: u64, x: f64, color: Color) -> Arc { Arc::new(Node::path( NodeId(id), @@ -350,6 +359,9 @@ mod tests { #[test] fn type_changes_the_result_of_every_pathfinder_effect() { + if skip_without_craft_fonts() { + return; + } let glyphs = crate::outline_text(&type_h()).unwrap(); let b = glyphs.geometric_bounds().unwrap(); assert!(b.x0 < 100.0 && b.x1 > 100.0 && b.y0 > 0.0 && b.y1 < 100.0, "{b:?}"); @@ -377,6 +389,9 @@ mod tests { #[test] fn pieces_of_type_keep_its_paint_opacity_and_blend_mode() { + if skip_without_craft_fonts() { + return; + } let mut t = type_h(); (t.opacity, t.blend) = (0.5, BlendMode::Multiply); let look = |p: &Arc| (p.appearance.fill_paint(), p.opacity, p.blend); @@ -418,6 +433,9 @@ mod tests { #[test] fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() { + if skip_without_craft_fonts() { + return; + } use vectorcraft_color::BlendMode; let art = type_in_group(|t| (t.opacity, t.blend) = (0.5, BlendMode::Multiply)); assert!(art.id == NodeId(3) && !matches!(art.kind, NodeKind::Text(_)), "{art:?}"); @@ -447,6 +465,9 @@ mod tests { #[test] fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_on_its_paths() { + if skip_without_craft_fonts() { + return; + } use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); From 6939a481a43b022263848a0cbe5aa88b1b5c2087 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:03:37 +0200 Subject: [PATCH 053/208] Dimension tool: Shift shows the constrained line while picking the second point (#2432) The Shift click already snapped the second point to 45-degree steps, but the dashed line from the first point followed the raw pointer, so the constraint looked absent until the click. Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/dimensions.rs | 18 ++++++++++++++++++ crates/tools/src/dimension.rs | 14 ++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/engine/tests/dimensions.rs b/crates/engine/tests/dimensions.rs index f65a292c7..83e7be165 100644 --- a/crates/engine/tests/dimensions.rs +++ b/crates/engine/tests/dimensions.rs @@ -389,3 +389,21 @@ fn unknown_dimension_options_are_refused_by_commands_but_a_newer_file_still_open let doc = vectorcraft_format::load(&serde_json::to_vec(&v).unwrap()).unwrap(); assert!(doc.node(NodeId(id)).unwrap().extras().dimension.is_some()); } + +/// With Shift down, the dashed line from the first picked point follows the 45-degree step the +/// second click will pick (#2432), not the raw pointer. +#[test] +fn shift_shows_the_constrained_line_while_picking_the_second_point() { + use vectorcraft_engine::tools::Overlay; + let mut s = session(); + s.select_tool("dimension", ViewInfo::default()).unwrap(); + ev(&mut s, PointerKind::Down, 300.0, 300.0); + ev(&mut s, PointerKind::Up, 300.0, 300.0); + let end = |s: &mut Session, shift: bool| { + let e = PointerEvent::new(PointerKind::Move, 372.0, 310.0).with_mods(Mods { shift, ..Mods::default() }); + s.pointer(&e, ViewInfo::default()).unwrap(); + s.overlays(ViewInfo::default()).into_iter().find_map(|o| if let Overlay::Line { b, .. } = o { Some(b) } else { None }).unwrap() + }; + assert_eq!(end(&mut s, false).y, 310.0); + assert!((end(&mut s, true).y - 300.0).abs() < 1e-9); +} diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index eb482a036..9fd871c70 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -60,9 +60,17 @@ pub struct DimensionTool { dragging: bool, hover: Option, at: Point, + /// Whether Shift was down at the last pointer event: the picked line shows constrained. + shift: bool, began: bool, } +/// `at` as a Shift click picks it: the direction from `last` snapped to 45° steps (from the +/// constrain angle), the distance kept. +fn constrained(cx: &ToolContext, last: Point, at: Point) -> Point { + last + vectorcraft_geom::constrain_angle_from(at - last, 45.0, cx.constrain_angle) +} + /// Nearest editable segment, with bounded work and no snapping to dimension artwork itself. pub fn pick(cx: &ToolContext, p: Point) -> Option { let mut best = None; @@ -217,7 +225,7 @@ impl DimensionTool { if mods.shift && let Some((last, _)) = self.points.last() { - at = *last + vectorcraft_geom::constrain_angle_from(at - *last, 45.0, cx.constrain_angle); + at = constrained(cx, *last, at); reference = reference.filter(|r| r.position(cx.doc) == Some(at)); } self.points.push((at, reference)); @@ -293,6 +301,7 @@ impl Tool for DimensionTool { } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { self.at = ev.pos; + self.shift = ev.mods.shift; match ev.kind { PointerKind::Move => { if self.pending.is_some() { @@ -393,7 +402,8 @@ impl Tool for DimensionTool { segment(edge, &mut out); } if let Some((p, _)) = self.points.last() { - out.push(Overlay::Line { a: *p, b: self.at, color, dashed: true }); + let b = if self.shift { constrained(cx, *p, self.at) } else { self.at }; + out.push(Overlay::Line { a: *p, b, color, dashed: true }); } out } From 101fd55fa009862a2c7f580fcc1202b1527db108 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:11:01 +0200 Subject: [PATCH 054/208] Type: a tab takes its width when area type breaks and justifies its lines (#2457) Line breaking, the line's natural width (and so justification) and the composer counted a tab as its glyph's width, a space, while it is set up to its next tab stop. A paragraph starting with a tab, as in the report, filled a justified line as if the tab were one space, and the line ran past the frame. break_line and the line width now measure tabs with tab_advance, and a paragraph with a tab is broken line by line, since the composer works with fixed glyph widths. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 37 +++++++++++++++++++++++++++++-------- crates/text/src/tests.rs | 15 +++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c56e0480d..0559730e2 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1162,6 +1162,16 @@ fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, re ((at - rel).max(0.0), leader) } +/// The width of glyphs `from..to` of `sg` set on a line whose content starts `x_start` from the +/// tab origin: tabs take what [`tab_advance`] gives them, not the width of their glyph. +fn set_width(sg: &[SGlyph], from: usize, to: usize, tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 { + let mut x = x_start; + for (j, g) in sg.iter().enumerate().take(to).skip(from) { + x += if g.ch == '\t' { tab_advance(tabs, 0.0, x, sg.get(j + 1..to).unwrap_or_default()).0 } else { g.adv }; + } + x - x_start +} + /// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make /// millions of outlines. const MAX_LEADER_COPIES: f64 = 4096.0; @@ -1223,12 +1233,16 @@ fn word_width(g: &[SGlyph], i: usize) -> f64 { /// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it. /// `hyphenate` holds the paragraph's Hyphenation options and the Hyphenation Zone in force when /// the line may hyphenate a word; a line whose text before the overflowing word already ends -/// within the zone breaks before that word. +/// within the zone breaks before that word. A tab is as wide as it is set: up to its next stop +/// (`tabs`), with the line's content starting `x_start` from the tab origin. +#[allow(clippy::too_many_arguments)] fn break_line( text: &str, g: &[SGlyph], i: usize, width: f64, + tabs: &[vectorcraft_doc::TabStop], + x_start: f64, hyphenate: Option<(&HyphenationOptions, f64)>, burasagari: Burasagari, kinsoku: Kinsoku, @@ -1245,7 +1259,11 @@ fn break_line( while g.get(cluster_end).is_some_and(|next| next.byte == gl.byte) { cluster_end += 1; } - let cluster_width: f64 = g[j..cluster_end].iter().map(|glyph| glyph.adv).sum(); + let cluster_width: f64 = if gl.ch == '\t' { + tab_advance(tabs, 0.0, x_start + x, g.get(cluster_end..).unwrap_or_default()).0 + } else { + g[j..cluster_end].iter().map(|glyph| glyph.adv).sum() + }; if j > i && !gl.is_space() && x + cluster_width > width + EPS { // It ends the line with the spaces after it. if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) { @@ -1390,7 +1408,7 @@ mod wrapping_tests { let text = "بَت"; let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]); - let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, None, Burasagari::None, Kinsoku::Hard); + let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); assert!(!hyphenated); assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together"); @@ -1403,11 +1421,11 @@ mod wrapping_tests { let text = "بَتَث"; let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]); - let (first_end, _) = break_line(text, &glyphs, 0, 3.0, None, Burasagari::None, Kinsoku::Hard); + let (first_end, _) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); assert_eq!(first_end, 1); assert_eq!(source_range(&glyphs, 0, first_end), 0..4); - let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, None, Burasagari::None, Kinsoku::Hard); + let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); assert!(!hyphenated); assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together"); @@ -1567,7 +1585,10 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>, rt // A paragraph whose commas or full stops may hang is composed line by line (as the Japanese // single-line composer does). let may_hang = para.burasagari != Burasagari::None && sg.iter().any(hangs); - if composer != Composer::EveryLine || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang { + // A tab's width depends on where it falls in its line, which the composer's fixed glyph widths + // can't follow: such a paragraph is broken line by line ([`break_line`] measures its tabs). + let has_tab = sg.iter().any(|g| g.ch == '\t'); + if composer != Composer::EveryLine || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang || has_tab { return None; } let m = Metrics::of(&sg[0]); @@ -1695,7 +1716,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio let hyphenate = (para.hyphenate && (hyphen_limit == 0 || hyphen_run < hyphen_limit)).then_some((¶.hyphenation, zone)); let (end, hyph) = match composed.as_ref().and_then(|c| c.get(li_para)) { Some(&(e, h)) if e > i => (e, h), - _ if i < n => break_line(cx.text, &sg, i, width, hyphenate, para.burasagari, para.kinsoku), + _ if i < n => break_line(cx.text, &sg, i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku), _ => (n, false), }; let m = Metrics::max(&sg[i..end]).unwrap_or(pm); @@ -1739,7 +1760,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio } None => 0.0, }; - let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum::() + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim; + let w: f64 = set_width(&sg, i, trimmed, ¶.tabs, ind_l) + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim; // Burasagari: a comma or full stop ending an area type line hangs outside it (Standard: // when it doesn't fit; Forced: always). The rest of the line is aligned and justified // without it, and it follows the line's last character. diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index d7c52af75..41718cf3d 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -486,6 +486,21 @@ fn tab_stops_position_text() { assert!((x_of(&p, 4) - 72.0).abs() < 1e-6); } +#[test] +fn a_leading_tab_takes_its_width_in_the_line_and_the_justification() { + // A tab at the start of area type is 36 pt wide: the line breaks and justifies around that, + // so no line reaches past the frame's right edge (#2457). + for justify in [Justify::Left, Justify::JustifyLeft] { + let t = + area("\tSample copy flows here while the layout takes shape. Swap these words", style(12.0), Rect::new(0.0, 0.0, 200.0, 400.0), justify); + let lay = layout(db(), &t); + let right = lay.glyphs.iter().filter(|g| !t.plain_text()[g.byte..].starts_with(' ')).map(|g| g.origin.x + g.advance).fold(0.0, f64::max); + assert!(right <= 200.0 + 1e-6, "{justify:?}: the type reaches {right}"); + let first = lay.glyphs.iter().find(|g| g.byte == 1).map(|g| g.origin.x).unwrap(); + assert!((first - 36.0).abs() < 1e-6, "{justify:?}: the text after the tab starts at {first}"); + } +} + #[test] fn tab_leaders_line_up_from_line_to_line_and_stop_short_of_the_stop() { use vectorcraft_doc::{TabAlign, TabStop}; From 848e7a296a55010c6b0f82a686987200e6a6c698 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:09:50 +0200 Subject: [PATCH 055/208] Dimension tool: a click near a line snaps the measured point onto the line (#2432) Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/dimensions.rs | 14 ++++++++++++++ crates/tools/src/dimension.rs | 27 ++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/engine/tests/dimensions.rs b/crates/engine/tests/dimensions.rs index 83e7be165..05915505c 100644 --- a/crates/engine/tests/dimensions.rs +++ b/crates/engine/tests/dimensions.rs @@ -407,3 +407,17 @@ fn shift_shows_the_constrained_line_while_picking_the_second_point() { assert_eq!(end(&mut s, false).y, 310.0); assert!((end(&mut s, true).y - 300.0).abs() < 1e-9); } + +/// A click near a line, away from its anchors, snaps to the closest point of the line (#2432). +#[test] +fn clicking_near_a_line_snaps_the_measured_point_onto_it() { + let mut s = session(); + edge(&mut s); + s.select_tool("dimension", ViewInfo::default()).unwrap(); + for (x, y) in [(130.0, 103.0), (150.0, 97.0), (140.0, 130.0)] { + ev(&mut s, PointerKind::Down, x, y); + ev(&mut s, PointerKind::Up, x, y); + } + let id = s.doc().unwrap().selection.objects[0].0; + assert_eq!(text(&s, id), "20.00 pt"); +} diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index 9fd871c70..2cd39dfc6 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -119,6 +119,20 @@ fn anchor_near(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option<(usize, .min_by(|(_, a), (_, b)| a.distance(p).total_cmp(&b.distance(p))) } +/// The point of `edge` closest to `p`. +fn nearest_on(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option { + use vectorcraft_geom::kurbo::ParamCurveNearest; + let sp = cx.doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?; + if edge.segment >= sp.segment_count() { + return None; + } + let c = sp.segment(edge.segment); + let t = c.nearest(p, 1e-4).t; + use vectorcraft_geom::kurbo::ParamCurve; + let q = c.eval(t); + (q.x.is_finite() && q.y.is_finite()).then_some(q) +} + impl DimensionTool { fn target(&self, cx: &ToolContext, p: Point) -> Option { let edge = pick(cx, p)?; @@ -216,11 +230,14 @@ impl DimensionTool { Kind::Linear => { let (mut at, _) = crate::guides::snap_pick(cx, p); let mut reference = None; - if let Some(edge) = pick(cx, p) - && let Some((anchor, point)) = anchor_near(cx, &edge, p) - { - at = point; - reference = Some(PointRef { node: edge.node, subpath: edge.subpath, anchor }); + if let Some(edge) = pick(cx, p) { + if let Some((anchor, point)) = anchor_near(cx, &edge, p) { + at = point; + reference = Some(PointRef { node: edge.node, subpath: edge.subpath, anchor }); + } else if let Some(point) = nearest_on(cx, &edge, p) { + // Not on an anchor: snap to the closest point of the line (#2432). + at = point; + } } if mods.shift && let Some((last, _)) = self.points.last() From 2108c3cc9931d1b74b3d64415207fd2231c343f8 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 21:02:55 +0200 Subject: [PATCH 056/208] Render: a glow's pixmap is drawn without the paint transform a pattern fill left (#2426) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_pattern.rs | 40 ++++++++++++++++++++++++++++++ crates/render/src/group.rs | 3 +++ 2 files changed, 43 insertions(+) diff --git a/crates/engine/src/tests_pattern.rs b/crates/engine/src/tests_pattern.rs index 5b5789d95..9aeff6c70 100644 --- a/crates/engine/src/tests_pattern.rs +++ b/crates/engine/src/tests_pattern.rs @@ -115,6 +115,8 @@ fn edit_then_cancel_restores_definition() { fn edit_defaults_to_selected_objects_pattern() { let mut s = session(); make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 100.0, 100.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); sel(&mut s, &[big]); @@ -129,6 +131,8 @@ fn edit_defaults_to_selected_objects_pattern() { fn set_fill_with_pattern_swatch() { let mut s = session(); make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 100.0, 100.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); assert!(matches!(node(&s, big).appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Dots")); @@ -170,6 +174,8 @@ fn options_set_tile_type_spacing_and_overlap() { fn options_rename_updates_swatch_and_paints() { let mut s = session(); make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); 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("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap(); @@ -182,6 +188,8 @@ fn options_rename_updates_swatch_and_paints() { fn delete_pattern_unpaints_objects() { let mut s = session(); make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let v = s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap(); @@ -195,6 +203,8 @@ fn delete_pattern_unpaints_objects() { fn transform_patterns_only_moves_placement() { let mut s = session(); make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let before = node(&s, big).geometric_bounds(); @@ -212,6 +222,8 @@ fn transform_patterns_only_moves_placement() { fn transforms_without_transform_objects_move_only_the_pattern() { let mut s = session(); make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); 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("select.set", &json!({"ids": [big.0]})).unwrap(); @@ -296,6 +308,8 @@ fn hit_testing_in_edit_mode_only_reaches_tile_layer() { fn save_load_roundtrip_keeps_patterns_and_repeats() { let mut s = session(); make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let r = rect(&mut s, 300.0, 50.0, 10.0, 10.0); @@ -312,6 +326,8 @@ fn save_load_roundtrip_keeps_patterns_and_repeats() { fn svg_export_writes_pattern_def() { let mut s = session(); make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let svg = vectorcraft_svg::export(doc(&s), &vectorcraft_svg::ExportOptions::default()); @@ -329,6 +345,8 @@ fn svg_export_writes_pattern_def() { fn pdf_export_expands_pattern_fills() { let mut s = session(); make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let rep = vectorcraft_pdf::export_with_report(doc(&s), &Default::default()).unwrap(); @@ -587,3 +605,25 @@ fn picking_another_pattern_swatch_keeps_the_pattern_transform() { assert_eq!(pattern, "Dots"); assert_eq!(xf.as_coeffs(), [2.0, 0.0, 0.0, 2.0, 15.0, 10.0]); } + +#[test] +fn inner_glow_on_a_pattern_fill_stays_inside_the_object_on_the_threaded_renderer() { + // #2426: the glow's pixmap was drawn with the pattern's paint transform, so it landed + // scaled at the top left instead of along the object's edges. + let mut s = session(); + make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); + let big = rect(&mut s, 100.0, 100.0, 150.0, 150.0); + s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); + sel(&mut s, &[big]); + s.execute("effect.apply", &json!({"effect": "stylize.innerGlow", "params": {"blur": 10}})).unwrap(); + let mut r = vectorcraft_render::Renderer::new(); + r.threads = 2; + let view = Affine::scale(1.5) * Affine::translate((-20.0, -30.0)); + let out = r.render(&s.doc().unwrap().doc, 400, 400, view, &Default::default()); + let [red, ..] = out.pixel(110, 100); + assert!(red < 0x30, "glow outside the object: red {red}"); + let [red, ..] = out.pixel(123, 200); + assert!(red > 0x40, "no glow along the edge: red {red}"); +} diff --git a/crates/render/src/group.rs b/crates/render/src/group.rs index e32be592d..c7f7b5acd 100644 --- a/crates/render/src/group.rs +++ b/crates/render/src/group.rs @@ -274,6 +274,9 @@ pub(crate) fn draw_pixmap(ctx: &mut RenderContext, pm: Arc, at: (f64, f6 #[cfg(test)] PIXELS_DRAWN.with(|n| n.set(n.get() + u64::from(pm.width()) * u64::from(pm.height()))); ctx.set_transform(Affine::translate(at)); + // The pixmap maps 1:1 onto the rect: a paint transform left by an earlier fill (a pattern, + // radial or freeform gradient) would resample it (#2426). + ctx.reset_paint_transform(); ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() }); ctx.fill_rect(&Rect::new(0.0, 0.0, w, h)); ctx.set_transform(Affine::IDENTITY); From 8875b3fbca7ab7557ec51114ee3fcdfd1c992701 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:07:11 +0200 Subject: [PATCH 057/208] Live Corners: a corner rounds until its cut reaches its neighbours (#2332) A corner's radius was capped at half its shorter side whatever its neighbours did, so one corner of a 100 pt square rounded alone stopped at 50 pt. Each corner now rounds until its cut reaches the cuts its neighbours ask for: the whole side when the neighbour is square, halfway when both ask for more. The corner widget drag, the red limit outline and CSS border-radius export follow the same limit. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/corners.rs | 13 +++- crates/geom/src/corners.rs | 117 ++++++++++++++++++++++++++++++---- crates/geom/src/shapes.rs | 23 ++++++- crates/svg/src/css.rs | 6 +- crates/svg/tests/css_rules.rs | 6 +- crates/tools/src/corners.rs | 58 ++++++++++++++--- docs/ui-parity.md | 5 +- 7 files changed, 192 insertions(+), 36 deletions(-) diff --git a/crates/doc/src/corners.rs b/crates/doc/src/corners.rs index 986442b29..ccf884fda 100644 --- a/crates/doc/src/corners.rs +++ b/crates/doc/src/corners.rs @@ -5,7 +5,7 @@ use std::borrow::Cow; use std::collections::BTreeSet; -use vectorcraft_geom::corners::{Corner, cut_corners, path_corners}; +use vectorcraft_geom::corners::{Corner, cut_corners, fitted_radii, path_corners}; use vectorcraft_geom::shapes::{self, CornerKind}; use vectorcraft_geom::{Affine, PathData, Rect}; @@ -23,6 +23,8 @@ pub struct LiveCorners<'a> { /// The corners that can be cut, in anchor order ([`path_corners`] of `base`). pub corners: Vec, radii: Vec, + /// The radius each of `corners` is drawn with ([`fitted_radii`]). + drawn: Vec, kinds: Vec, /// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from. sources: Vec>, @@ -54,7 +56,9 @@ impl<'a> LiveCorners<'a> { }) .collect() }; - Some(Self { corners: path_corners(&base), base, xf, radii, kinds, sources }) + let corners = path_corners(&base); + let drawn = fitted_radii(&corners, &radii); + Some(Self { corners, base, xf, radii, drawn, kinds, sources }) } /// The Live Corners of a path object. @@ -73,6 +77,11 @@ impl<'a> LiveCorners<'a> { self.radii.get(index).copied().unwrap_or(0.0) } + /// The radius corner `index` is drawn with, in document units: its own, no larger than its + /// neighbours leave room for (0 for an anchor that is no corner). + pub fn drawn_radius(&self, index: usize) -> f64 { + self.corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| self.drawn.get(i)).copied().unwrap_or(0.0) + } /// The kind corner `index` is cut with. pub fn kind(&self, index: usize) -> CornerKind { self.kinds.get(index).copied().unwrap_or_default() diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index 31e03026a..16120aa0e 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -76,6 +76,10 @@ pub struct Corner { /// The sides themselves, leaving the corner, in the same order. pub side_u: Side, pub side_v: Side, + /// The anchor indices (in the path) of the anchors at the other ends of the sides, in the + /// same order. + pub before: usize, + pub after: usize, } impl Corner { @@ -84,8 +88,10 @@ impl Corner { /// sides leave it straight on. fn of(sp: &SubPath, i: usize, index: usize) -> Option { let n = sp.anchors.len(); - let (prev, next) = if sp.closed { ((i + n).checked_sub(1)? % n, (i + 1) % n) } else { (i.checked_sub(1)?, i + 1) }; - let (a, prev, next) = (sp.anchors.get(i)?, sp.anchors.get(prev)?, sp.anchors.get(next)?); + let (prev_i, next_i) = if sp.closed { ((i + n).checked_sub(1)? % n, (i + 1) % n) } else { (i.checked_sub(1)?, i + 1) }; + let (a, prev, next) = (sp.anchors.get(i)?, sp.anchors.get(prev_i)?, sp.anchors.get(next_i)?); + let first = index.checked_sub(i)?; + let (before, after) = (first + prev_i, first + next_i); if a.kind == AnchorKind::Smooth { return None; } @@ -96,7 +102,7 @@ impl Corner { if !(lu > EPS && lv > EPS && lu.is_finite() && lv.is_finite()) { return None; } - let c = Self { index, at: a.p, u, v, lu, lv, side_u, side_v }; + let c = Self { index, at: a.p, u, v, lu, lv, side_u, side_v, before, after }; (c.sin() > MIN_SINE).then_some(c) } @@ -120,19 +126,51 @@ impl Corner { r * (1.0 + self.cos()) / self.sin() } - /// The largest radius the corner draws: its cut then reaches halfway along the shorter side, - /// so neighbouring corners at their largest meet without overlapping (half the shorter side - /// at a rectangle's corners). - pub fn max_radius(&self) -> f64 { - self.lu.min(self.lv) / 2.0 * self.sin() / (1.0 + self.cos()) + /// The radius of a round cut that meets each side `s` from the corner (the inverse of + /// [`Corner::setback`]). + pub fn radius_at_setback(&self, s: f64) -> f64 { + s * self.sin() / (1.0 + self.cos()) } - /// The radius the corner is drawn with: `radius`, no larger than [`Corner::max_radius`], 0 when - /// negative or not a number. + /// The largest radius the corner draws whatever its neighbours do: its cut then reaches + /// halfway along the shorter side, so neighbouring corners at their largest meet without + /// overlapping (half the shorter side at a rectangle's corners). + pub fn max_radius(&self) -> f64 { + self.radius_at_setback(self.lu.min(self.lv) / 2.0) + } + + /// The largest radius the corner draws when the cuts of the corners at the other ends of its + /// sides reach `before` along side u and `after` along side v: its cut reaches halfway along + /// a side, or further, up to where the other end's cut begins, so all of a side whose other + /// end isn't cut (#2332). + pub fn max_radius_beside(&self, before: f64, after: f64) -> f64 { + let room = |len: f64, other: f64| (len - other.max(0.0)).max(len / 2.0); + self.radius_at_setback(room(self.lu, before).min(room(self.lv, after))) + } + + /// The radius the corner is drawn with whatever its neighbours do: `radius`, no larger than + /// [`Corner::max_radius`], 0 when negative or not a number. pub fn fitted(&self, radius: f64) -> f64 { radius.max(0.0).min(self.max_radius()) } + /// The largest radius the corner draws when it and the corners for which `edited` holds are + /// set to one radius together, the other corners of `corners` keeping their `radii` (by + /// anchor index). A side shared with an edited corner takes both cuts as far as they fit + /// side by side, or halfway each; on a side to any other corner the cut reaches up to where + /// that corner's cut begins ([`Corner::max_radius_beside`]). + pub fn max_shared_radius(&self, corners: &[Corner], radii: &[f64], edited: impl Fn(usize) -> bool) -> f64 { + let side = |len: f64, other: usize| match find(corners, other) { + Some(c) if other == self.index || edited(other) => { + let both = self.setback(1.0) + c.setback(1.0); + let apart = if both > EPS { len / both } else { f64::INFINITY }; + apart.max(self.radius_at_setback(len / 2.0)) + } + _ => self.radius_at_setback((len - requested_setback(corners, radii, other)).max(len / 2.0)), + }; + side(self.lu, self.before).min(side(self.lv, self.after)) + } + /// The point `k` along the bisector, measured in (u + v): the centre of a round cut of radius /// `r` is at `k = r / sin`. pub fn on_bisector(&self, k: f64) -> Point { @@ -237,15 +275,43 @@ pub fn path_corners(path: &PathData) -> Vec { out } +/// The corner at anchor `index` among `corners` (in anchor order). +fn find(corners: &[Corner], index: usize) -> Option<&Corner> { + corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| corners.get(i)) +} + +/// How far along each side the cut of the corner at anchor `index` asks to reach, with the radius +/// in `radii` there (0 for an anchor that is no corner, or a radius that is negative or not a +/// number). +fn requested_setback(corners: &[Corner], radii: &[f64], index: usize) -> f64 { + find(corners, index).map_or(0.0, |c| c.setback(radii.get(index).copied().unwrap_or(0.0).max(0.0))) +} + +/// The radius each of `corners` (in anchor order) is drawn with, given the radii by anchor index: +/// its own, no larger than leaves room for the cuts of its neighbours ([`Corner::max_radius_beside`] +/// with the setbacks their radii ask for), so a corner whose neighbours are square rounds until +/// its cut reaches them (#2332) and two neighbours never cut past each other. +pub fn fitted_radii(corners: &[Corner], radii: &[f64]) -> Vec { + corners + .iter() + .map(|c| { + let own = radii.get(c.index).copied().unwrap_or(0.0).max(0.0); + let max = c.max_radius_beside(requested_setback(corners, radii, c.before), requested_setback(corners, radii, c.after)); + own.min(max) + }) + .collect() +} + /// `path` with each of its corners ([`path_corners`]) cut by the radius in `radii` and the kind in /// `kinds` at its anchor index (missing: 0 and round), each radius no larger than fits -/// ([`Corner::fitted`]). A cut corner becomes two anchors, and a curved side between corners is +/// ([`fitted_radii`]). A cut corner becomes two anchors, and a curved side between corners is /// trimmed to what is left of it; a closed subpath whose first corner is cut starts where that /// cut ends, so the cut closes it. Also returns, per subpath, the anchor index in `path` each /// anchor comes from. pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (PathData, Vec>) { let corners = path_corners(path); - let mut corners = corners.iter().peekable(); + let fitted = fitted_radii(&corners, radii); + let mut corners = corners.iter().zip(fitted).peekable(); let (mut subpaths, mut sources) = (Vec::with_capacity(path.subpaths.len()), Vec::with_capacity(path.subpaths.len())); let mut first = 0; for sp in &path.subpaths { @@ -254,8 +320,7 @@ pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (Pat let cuts: Vec> = (0..n) .map(|i| { let index = first + i; - let c = corners.next_if(|c| c.index == index)?; - let radius = c.fitted(radii.get(index).copied().unwrap_or(0.0)); + let (c, radius) = corners.next_if(|(c, _)| c.index == index)?; (radius > EPS).then(|| c.cut(radius, kinds.get(index).copied().unwrap_or_default())) }) .collect(); @@ -470,4 +535,28 @@ mod tests { assert!((bez.eval(t).distance(c.at) - r).abs() < r * 1e-3); } } + + /// #2332: a corner rounds until its cut reaches its neighbours' (all of a side whose other + /// end is square); neighbours that both ask for more than half a side meet halfway. + #[test] + fn a_corner_rounds_up_to_its_neighbours_cuts() { + let square = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); + let corners = path_corners(&square); + let drawn = |radii: [f64; 4]| fitted_radii(&corners, &radii); + assert_eq!(drawn([0.0, 500.0, 0.0, 0.0]), [0.0, 100.0, 0.0, 0.0]); + assert_eq!(drawn([30.0, 500.0, 0.0, 0.0]), [30.0, 70.0, 0.0, 0.0]); + assert_eq!(drawn([80.0, 500.0, 0.0, 0.0]), [50.0, 50.0, 0.0, 0.0]); + assert_eq!(drawn([500.0; 4]), [50.0; 4]); + assert_eq!(drawn([f64::NAN, 120.0, -1.0, 0.0]), [0.0, 100.0, 0.0, 0.0]); + // The lone cut is a quarter circle from one neighbour to the other. + let (cut, _) = cut_corners(&square, &[0.0, 500.0, 0.0, 0.0], &[]); + let ends: Vec = cut.anchors().map(|(_, _, a)| a.p).collect(); + assert_eq!(ends, [Point::new(0.0, 0.0), Point::new(0.0, 0.0), Point::new(100.0, 100.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)]); + // Dragged together with its neighbour, a corner meets it halfway; beside a square one, + // it goes all the way. + let tr = corners[1]; + assert!((tr.max_shared_radius(&corners, &[0.0; 4], |k| k == 0) - 50.0).abs() < 1e-9); + assert!((tr.max_shared_radius(&corners, &[0.0; 4], |_| false) - 100.0).abs() < 1e-9); + assert!((tr.max_shared_radius(&corners, &[0.0, 0.0, 40.0, 0.0], |_| false) - 60.0).abs() < 1e-9); + } } diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs index 5aa16b639..54a7ad933 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -59,8 +59,23 @@ pub fn fitted_corner_radius(w: f64, h: f64, radius: f64) -> f64 { radius.max(0.0).min(max_corner_radius(w, h)) } +/// The radii the corners of a `w` × `h` rectangle (top-left, top-right, bottom-right, +/// bottom-left) are drawn with: each its own, no larger than leaves room for its neighbours' +/// cuts ([`crate::corners::fitted_radii`]): half the shorter side when they are all as large, up +/// to a whole side when its neighbours are square (#2332); 0 when negative or not a number. +pub fn fitted_corner_radii(w: f64, h: f64, radii: [f64; 4]) -> [f64; 4] { + let corners = crate::corners::path_corners(&rectangle(Rect::new(0.0, 0.0, w, h))); + let mut out = [0.0; 4]; + for (c, r) in corners.iter().zip(crate::corners::fitted_radii(&corners, &radii)) { + if let Some(o) = out.get_mut(c.index) { + *o = r; + } + } + out +} + /// Rectangle whose corners (top-left, top-right, bottom-right, bottom-left, y down) each have -/// their own radius and kind, every radius clamped to half the shorter side. A corner without a +/// their own radius and kind, every radius no larger than [`fitted_corner_radii`] leaves. A corner without a /// radius is one anchor, the others two (where the cut meets each side). Clockwise from the /// top-left corner, its anchor on the top side first. Cut as any path's corners are /// ([`crate::corners::cut_corners`]). @@ -379,9 +394,11 @@ mod tests { let chamfer = rectangle_with_corners(r, radii, [CornerKind::Chamfer; 4]); assert!((chamfer.subpaths[0].area() - (6000.0 - 50.0)).abs() < 1e-9); assert!(chamfer.subpaths[0].anchors.iter().all(|a| !a.has_in() && !a.has_out()), "straight"); - // Radii clamp to half the shorter side; negative and NaN radii are none. + // Radii clamp to what their neighbours leave, the whole shorter side when they are + // square (#2332): the cut reaches the bottom-left corner. Negative and NaN radii are none. let p = rectangle_with_corners(r, [500.0, -5.0, f64::NAN, 0.0], [CornerKind::Round; 4]); - assert_eq!(p.subpaths[0].anchors[0].p, Point::new(30.0, 0.0)); + assert_eq!(p.subpaths[0].anchors[0].p, Point::new(60.0, 0.0)); + assert_eq!(p.subpaths[0].anchors.last().map(|a| a.p), Some(Point::new(0.0, 60.0))); assert_eq!(p.anchor_count(), 5); assert_eq!(CornerKind::ALL.map(CornerKind::next), [CornerKind::InvertedRound, CornerKind::Chamfer, CornerKind::Round]); } diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index aef875a40..34e74269e 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -622,9 +622,9 @@ fn corners(path: &PathData, live: Option<&LiveShape>) -> Option { Some(LiveShape::Rectangle { w, h, radii, kinds, xf }) if upright(xf) && radii.iter().zip(kinds).all(|(r, k)| *r <= 0.0 || *k == CornerKind::Round) => { - // The radii as drawn, none past half the shorter side (CSS draws one further when its - // neighbours leave room). - let radii = radii.map(|r| shapes::fitted_corner_radius(*w, *h, r)); + // The radii as drawn: no two neighbours' cuts add up to more than their side, so CSS + // draws them as they are. + let radii = shapes::fitted_corner_radii(*w, *h, *radii); if radii.iter().all(|r| *r <= 0.0) { Some(Corners::Square) } else if radii.iter().all(|r| (r - radii[0]).abs() < 1e-9) { diff --git a/crates/svg/tests/css_rules.rs b/crates/svg/tests/css_rules.rs index 654a522db..2b4a203d9 100644 --- a/crates/svg/tests/css_rules.rs +++ b/crates/svg/tests/css_rules.rs @@ -84,13 +84,13 @@ fn live_corners_give_per_corner_radii_and_only_round_ones() { kinds[1] = CornerKind::InvertedRound; } assert!(prop(&one(&d, id, &CssOptions::default()), "border-radius").is_none(), "CSS can't cut a corner in"); - // A radius past half the shorter side is drawn at half of it (#442), and exported so: CSS - // would draw it larger, its neighbours leaving room. + // A radius is drawn no larger than its neighbours leave room for, past half the shorter + // side when they are small (#2332), and exported as drawn: 50 less the 4 below it. if let Some(LiveShape::Rectangle { radii, kinds, .. }) = d.node_mut(id).and_then(|n| n.live_shape_mut()) { *radii = [60.0, 8.0, 0.0, 4.0]; *kinds = Default::default(); } - assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("25px 8px 0 4px")); + assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("46px 8px 0 4px")); } #[test] diff --git a/crates/tools/src/corners.rs b/crates/tools/src/corners.rs index affb049f2..c3be27432 100644 --- a/crates/tools/src/corners.rs +++ b/crates/tools/src/corners.rs @@ -29,8 +29,10 @@ const MIN_SIDE_PX: f64 = 3.0 * MIN_INSET_PX; struct Widget { /// The corner, in the corners' own space. corner: Corner, - /// The radius it is set to (document units) and its kind. + /// The radius it is set to (document units), the one it is drawn with (no larger than its + /// neighbours leave room for) and its kind. radius: f64, + drawn: f64, kind: CornerKind, /// The angle between its sides in the document, in degrees. angle: f64, @@ -76,9 +78,9 @@ impl PathWidgets { .map(|c| { // On the bisector at the centre of the arc as drawn, at least the minimum inset // in and at most half the shorter side. - let radius = lc.radius(c.index); - let k = (c.fitted(radius) / c.sin()).max(MIN_INSET_PX / (scale * (1.0 + c.cos()).sqrt())).min(c.lu.min(c.lv) / 2.0); - Widget { corner: *c, radius, kind: lc.kind(c.index), angle: angle_in(lc.xf, c), at: lc.xf * c.on_bisector(k) } + let (radius, drawn) = (lc.radius(c.index), lc.drawn_radius(c.index)); + let k = (drawn / c.sin()).max(MIN_INSET_PX / (scale * (1.0 + c.cos()).sqrt())).min(c.lu.min(c.lv) / 2.0); + Widget { corner: *c, radius, drawn, kind: lc.kind(c.index), angle: angle_in(lc.xf, c), at: lc.xf * c.on_bisector(k) } }) .collect(); let all = widgets.len() == lc.corners.len(); @@ -217,6 +219,8 @@ pub fn double_click(cx: &ToolContext, p: Point, any_path: bool) -> Option, radii: Vec, kinds: Vec, xf: Affine, @@ -278,7 +282,7 @@ impl CornerDrag { let lc = cx.doc.node(w.id).and_then(LiveCorners::of)?; let n = lc.base.anchor_count(); let (radii, kinds) = ((0..n).map(|k| lc.radius(k)).collect(), (0..n).map(|k| lc.kind(k)).collect()); - Some(Outline { base: lc.base.into_owned(), radii, kinds, xf: w.xf }) + Some(Outline { corners: lc.corners.clone(), base: lc.base.into_owned(), radii, kinds, xf: w.xf }) }) .collect::>>()?; Some(Self { widgets, widget, outlines, start: p, began: false, alt: ev.mods.alt, radius, at_limit: false, at: p, click }) @@ -293,15 +297,23 @@ impl CornerDrag { return (self.radius, self.at_limit); }; let inv = xf.inverse(); - let max = w.corner.max_radius(); - let r = w.corner.fitted(w.radius) + w.corner.radius_change(inv * p - inv * self.start); + let max = self.max_radius(path, &w.corner); + let r = w.drawn + w.corner.radius_change(inv * p - inv * self.start); (r.min(max).max(0.0), max > 0.0 && r >= max) } + /// The largest radius `corner` of shown path `path` draws while the drag sets every shown + /// corner of that path to one radius: up to the cuts of its neighbours, as far as its own + /// sides when they are square (#2332). + fn max_radius(&self, path: usize, corner: &Corner) -> f64 { + let (Some(shown), Some(o)) = (self.widgets.paths.get(path), self.outlines.get(path)) else { return corner.max_radius() }; + corner.max_shared_radius(&o.corners, &o.radii, |k| shown.widgets.iter().any(|w| w.corner.index == k)) + } + /// The cuts of the edited corners that are at their largest radius, in document coordinates. fn corners_at_limit(&self) -> BezPath { let mut out = BezPath::new(); - for (shown, o) in self.widgets.paths.iter().zip(&self.outlines) { + for (n, (shown, o)) in self.widgets.paths.iter().zip(&self.outlines).enumerate() { let shown = &shown.widgets; let mut radii = o.radii.clone(); for w in shown { @@ -309,7 +321,7 @@ impl CornerDrag { *r = self.radius; } } - let full = |k: &usize| shown.iter().any(|w| w.corner.index == *k && self.radius >= w.corner.max_radius() - 1e-9); + let full = |k: &usize| shown.iter().any(|w| w.corner.index == *k && self.radius >= self.max_radius(n, &w.corner) - 1e-9); let (path, sources) = cut_corners(&o.base, &radii, &o.kinds); for (sp, from) in path.transformed(o.xf).subpaths.iter().zip(&sources) { // A corner's cut is the segment between its two anchors (the last one, for a @@ -611,6 +623,34 @@ mod tests { assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 175.0, 176.0)), vec![Action::Commit]); } + /// #2332: one corner rounded alone goes past half the side, until its cut reaches the + /// neighbouring anchors (the whole side of a square), or a rounded neighbour's cut. + #[test] + fn a_corner_rounded_alone_rounds_up_to_its_neighbours() { + for (top_right, max) in [(0.0, 100.0), (30.0, 70.0)] { + let live = LiveShape::Rectangle { + w: 100.0, + h: 100.0, + radii: [0.0, top_right, 0.0, 0.0], + kinds: Default::default(), + xf: Affine::translate((100.0, 100.0)), + }; + let (d, id) = doc_with(live.to_path(), Some(live)); + // Direct Selection picked the bottom-right anchor. + let mut s = selected(id); + s.anchors.insert(id, [(0, if top_right > 0.0 { 3 } else { 2 })].into()); + let p = paint(); + let cx = cx(&d, &s, &p); + assert_eq!(CornerWidgets::of(&d, &s, 1.0, true).unwrap().corners(), [2]); + let mut t = crate::create("directSelection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 190.0)); + assert!((radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 130.0))[1]) - 60.0).abs() < 1e-9); + assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { .. })), "60 is below the limit"); + assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 0.0, 0.0))[0]), max); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { .. })), "the limit shows"); + } + } + #[test] fn alt_click_cycles_the_kind_and_double_click_opens_the_dialog() { let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0))); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9e17ae045..9f963b248 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to, @@ -83,7 +83,7 @@ tools, never checked side by side) isn't captured row by row. | Cut path at selected anchors, Remove Anchor Points, Delete removes segments | done | Open-path anchor Delete removes adjoining segments without bridging the gap; targeted core and full-frame Delete/Backspace regressions pass for survivor selection and Undo/Redo. Explicit Remove Anchor Points keeps its reconnect/refit behavior; #1278: segment Delete preserves neighboring edges and endpoint anchors; a second Delete removes the remainder; explicit anchor Delete retains its behavior; #1674: Delete removes every selected anchor of a closed path and objects selected whole along with them, and keeps the remainder selected; #2241: Remove Anchor Points then selects the next anchor along the path (the one before at an open end), so it can be run again without picking each anchor | | Direct Selection and Group Selection pick only inside the isolated object in isolation mode | done | #1666 | | Escape cancels a Direct Selection drag | done | #1803 (#1743) | -| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512) | +| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512); a corner rounds until its cut reaches its neighbours' cuts, so one corner of a square alone reaches the whole side (#2332) | | Corners between curved sides (#1144), Relative rounding | partial | Relative rounding missing | | Pie widgets on a live ellipse: drag one to set Pie Start or End Angle, double-click one to make the ellipse whole | done | Selection tool, with the bounding box showing | | Isolation mode by double-click (groups and single objects), breadcrumbs, Esc to exit | done | Single paths, compound paths, images, meshes and placed files isolate too ([helpx: Isolate objects](https://helpx.adobe.com/africa/illustrator/desktop/manage-objects/select-objects/isolate-objects.html)) | @@ -234,6 +234,7 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, # | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Live Corners: a corner rounds until its cut reaches the neighbouring anchors or their cuts, so one corner of a 100 pt square alone reaches a 100 pt radius instead of 50 (#2332); score unchanged (estimated) | | 2026-10-16 | minor | Correction: quick Image Trace presets trace at once and leave the panel as it was (the 2026-10-14 row below said a quick preset reveals it; the later change in `baadbe2c6` made the button the way in); the Image Trace button in the Control bar or Properties opens the controls, as the public documentation describes. Wording only; scores and hours unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-16 | minor | Anchor Point tool: a click on one handle end retracts that handle only; the anchor becomes a corner and keeps the other handle (#2267). Handle drags start past the same 2 px as anchor drags. Scores and hours unchanged. | From 7cd6b5d983a75fcd9e9c44febdc2fb39b8fd2672 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:20:49 +0200 Subject: [PATCH 058/208] Effects: Expand Appearance and export give each Transform copy of a gradient path its own gradient (#2490) A path with a Transform effect that makes copies and a gradient was still merged into one path by bake_leaf and expand_leaf, so the gradient spanned every copy. Route it through reshape / expand_outlined like the renderer does. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/bake.rs | 39 ++++++++++++++++++++++++++++++ crates/effects/src/reshape.rs | 7 +++++- crates/engine/src/cmd/effectcmd.rs | 28 +++++++++++++++++++-- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index e0165c2ef..0f5b89d1c 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -120,6 +120,13 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { let m = reshape(n, symbol.as_deref())?; return Some(bake_node(d, &m).unwrap_or(m)); } + // A path whose Transform effect copies it with a gradient: each copy an object of its own, so + // its gradient fits it rather than spanning every copy. + if crate::has_gradient_copies(n) + && let Some(m) = reshape(n, None) + { + return Some(bake_pieces(d, m)); + } // Groups and layers: Pathfinder, geometry effects and their own fills and strokes (type that // Transform effects only move stays type). The new pieces (results, outlines, copies, the // fills' and strokes' art) get ids of their own. @@ -968,4 +975,36 @@ mod tests { // An effect that also scales. assert!(transform_offsets(&[json!({"moveH": 10, "scaleH": 50})], &square).is_none()); } + /// A 10 pt square painted with a red to blue gradient and a Transform effect with 2 copies. + fn gradient_copies(id: u64) -> Node { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }))); + let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0)); + n.appearance.effects.push(fx("distort.transform", json!({"moveH": 30, "copies": 2}))); + n + } + + /// The paths under `n` that paint a gradient fitted to their own 10 pt box, left to right. + fn gradient_pieces(n: &Node) -> Vec { + let mut out = vec![]; + let mut walk = vec![n]; + while let Some(n) = walk.pop() { + match n.children() { + Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => walk.extend(ch.iter().map(|c| &**c)), + _ => { + assert!(matches!(n.appearance.fill_paint(), Paint::Gradient(_)), "{n:?}"); + out.extend(n.geometric_bounds().map(|b| b.width())); + } + } + } + out + } + + #[test] + fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() { + let d = bake_document(&doc_with(gradient_copies(50))).unwrap(); + // Three squares of their own, not one path spanning all three. + assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]); + } } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index fa59cae55..e0c469718 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -198,10 +198,15 @@ pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option { as_art(n, symbol_art) } -/// `n` (see [`needs_outline`]) as plain art for Expand Appearance: [`reshape`]d when it has +/// `n` (see [`needs_outline`], or a path with [`has_gradient_copies`]) as plain art for Expand +/// Appearance: [`reshape`]d when it has /// geometry effects, else type with fills or strokes of its own outlined, in the same form. `None` /// when it has neither. pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option { + // A path whose Transform effect copies it with a gradient: each copy an object of its own. + if has_gradient_copies(n) { + return as_art(n, symbol_art); + } let own_paint = matches!(n.kind, NodeKind::Text(_)) && !n.appearance.items.is_empty(); if !needs_outline(n) || (geometry_effects(n).is_empty() && !own_paint) { return None; diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 1781f3355..abffe675c 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -504,10 +504,11 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b expand_pieces(d, &mut m, false, &mut stroke_art); m }) - } else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) { + } else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_gradient_copies(&v) { effects::expand_leaf(d, &v, &mut stroke_art) } else { - // Type, images, symbol instances, live objects: through their outlines. + // Type, images, symbol instances, live objects (and paths whose Transform effect copies + // them with a gradient): through their outlines. let symbol = match &n.kind { NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), _ => None, @@ -995,4 +996,27 @@ mod tests { assert_eq!(ch.len(), 1); assert!((ch[0].geometric_bounds().unwrap().width() - 50.0).abs() < 1e-6); } + #[test] + fn expand_appearance_gives_each_copy_of_a_transform_its_own_gradient() { + use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; + let (mut s, id) = session_with_rect(); + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }))); + s.edit("gradient", |d, _| { + let n = d.node_mut(id).unwrap(); + n.appearance.set_fill(ramp); + Ok(()) + }) + .unwrap(); + s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap(); + s.execute("effect.setParams", &json!({"index":0,"params":{"moveH":150,"copies":2},"ids":[id.0],"item":null})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + let mut boxes = vec![]; + node(&s, id).walk(&mut |n| { + if matches!(n.kind, NodeKind::Path { .. }) && matches!(n.appearance.fill_paint(), Paint::Gradient(_)) { + boxes.push(n.geometric_bounds().unwrap().width()); + } + }); + assert_eq!(boxes, vec![100.0; 3], "three squares, each with its own gradient"); + } } From d3751cc07bfe366a9cba188cf0df80a04121d88a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 19:53:49 +0200 Subject: [PATCH 059/208] Effects: Zig Zag Smooth points draw a continuous wave (#2343) Smooth Zig Zag ran the ridge points through a Catmull-Rom spline. Its end tangents slant across the path, and where the ridges are taller than about twice their spacing the sharp-turn floor of turn_tangent lengthens the handles, so the result read as straight diagonals with rounded tips. Each peak and valley is now a smooth point whose handles run along the path, a third of the way to the neighbouring ridge, so every span follows a half cosine through the path. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/distort.rs | 42 ++++++++++++++++++++++++++--------- crates/effects/src/tests.rs | 25 +++++++++++++++++++++ docs/effects-parity.md | 5 +++-- 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index b7cb564d5..502cf962a 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -267,8 +267,20 @@ pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData { const ZIGZAG_ARCLEN_ACCURACY: f64 = 1e-4; +/// A Zig Zag ridge: where it sits, the path's unit direction there, and the length along the +/// path to the ridges before and after it (zero at an open path's ends). +struct Ridge { + p: Point, + dir: Vec2, + gap_in: f64, + gap_out: f64, +} + /// Zig Zag: `ridges` extra points per segment, evenly spaced along its length and alternately -/// displaced along the normal. +/// displaced along the normal. Corner points join them with straight lines. Smooth points make a +/// wave: every peak and valley is a smooth point whose handles run along the path, a third of the +/// way to the next ridge, so each span between them follows a half cosine (an S through the path) +/// whatever the size of the ridges (#2343). pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData { let size = num(p, "size", 10.0).clamp(-1e4, 1e4); let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size }; @@ -277,29 +289,33 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData { let mut subs = vec![]; for sp in &path.subpaths { let segs = sp.segment_count(); - let mut pts = vec![]; + let gap = |i: usize| sp.segment(i).arclen(ZIGZAG_ARCLEN_ACCURACY) / (ridges + 1) as f64; + let mut pts: Vec = vec![]; let mut k = 0usize; for i in 0..segs { let c = sp.segment(i); // Ridges are spaced by length, not by `t`: a line's retracted handles bunch `t` up // near the anchors, and so does any uneven curve. let len = c.arclen(ZIGZAG_ARCLEN_ACCURACY); + let step = len / (ridges + 1) as f64; for j in 0..=ridges { let s = j as f64 / (ridges + 1) as f64; let t = if j > 0 && len > 1e-9 { c.inv_arclen(s * len, ZIGZAG_ARCLEN_ACCURACY).clamp(0.0, 1.0) } else { s }; + let mut gap_in = step; let nrm = if j == 0 { // Average the normals of the segments meeting at the anchor. let prev = if i > 0 { - Some(sp.segment(i - 1)) + Some(i - 1) } else if sp.closed { - Some(sp.segment(segs - 1)) + Some(segs - 1) } else { None }; + gap_in = prev.map_or(0.0, gap); let n1 = normal_at(&c, 0.0); match prev { - Some(pc) => { - let s = n1 + normal_at(&pc, 1.0); + Some(pi) => { + let s = n1 + normal_at(&sp.segment(pi), 1.0); if s.hypot() > 1e-9 { s / s.hypot() } else { n1 } } None => n1, @@ -308,7 +324,7 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData { normal_at(&c, t) }; let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 }; - pts.push(c.eval(t) + nrm * (amp * sign)); + pts.push(Ridge { p: c.eval(t) + nrm * (amp * sign), dir: Vec2::new(-nrm.y, nrm.x), gap_in, gap_out: step }); k += 1; } } @@ -316,12 +332,18 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData { if segs > 0 { let c = sp.segment(segs - 1); let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 }; - pts.push(c.p3 + normal_at(&c, 1.0) * (amp * sign)); + let nrm = normal_at(&c, 1.0); + pts.push(Ridge { p: c.p3 + nrm * (amp * sign), dir: Vec2::new(-nrm.y, nrm.x), gap_in: gap(segs - 1), gap_out: 0.0 }); } else { - pts.extend(sp.anchors.iter().map(|a| a.p)); + pts.extend(sp.anchors.iter().map(|a| Ridge { p: a.p, dir: Vec2::ZERO, gap_in: 0.0, gap_out: 0.0 })); } } - subs.push(if smooth { catmull_rom(&pts, sp.closed, 1.0) } else { SubPath::polyline(&pts, sp.closed) }); + subs.push(if smooth { + let anchors = pts.iter().map(|r| Anchor::with_handles(r.p, r.p - r.dir * (r.gap_in / 3.0), r.p + r.dir * (r.gap_out / 3.0))).collect(); + SubPath::new(anchors, sp.closed) + } else { + SubPath::polyline(&pts.iter().map(|r| r.p).collect::>(), sp.closed) + }); } PathData::new(subs) } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 9b90995bc..f075e4095 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -510,6 +510,31 @@ fn zigzag_spaces_ridges_evenly_along_a_straight_line() { } } +/// #2343: Smooth points make a continuous wave, not a zigzag with rounded tips. Every peak and +/// valley is a smooth point whose handles run parallel to the line, and the curve between them +/// follows a cosine, even where the ridges are much taller than they are apart. +#[test] +fn zigzag_smooth_is_a_wave() { + use vectorcraft_geom::kurbo::ParamCurve; + let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false)); + for size in [3.0, 30.0] { + let out = run("distort.zigZag", json!({"ridges": 9, "points": "smooth", "size": size}), &line); + let sp = &out.subpaths[0]; + assert_eq!(sp.anchors.len(), 11); + for a in &sp.anchors { + assert!(close(a.h_in.y, a.p.y, 1e-9) && close(a.h_out.y, a.p.y, 1e-9), "size {size}: handles not level at {a:?}"); + } + for i in 0..sp.segment_count() { + let c = sp.segment(i); + for k in 0..=20 { + let q = c.eval(k as f64 / 20.0); + let want = -size * (std::f64::consts::PI * q.x / 10.0).cos(); + assert!((q.y - want).abs() < 0.03 * size, "size {size}: {q:?} off the wave ({want})"); + } + } + } +} + /// #1554 on curves: a circle's ridges sit at equal angles, at the radius plus and minus the size in /// turn all the way round. #[test] diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 09ac1f415..2379ba760 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -1,6 +1,6 @@ # Effects parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); Round Corners also rounds corners where a curve meets a line or another curve, with Live Corners' cut; straight corners unchanged; score unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Zig Zag's Smooth points make a continuous wave, #2343; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); Round Corners also rounds corners where a curve meets a line or another curve, with Live Corners' cut; straight corners unchanged; score unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x) Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue (`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it @@ -22,7 +22,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( |---|---|---:|---| | Convert to Shape | Rectangle, Rounded Rectangle, Ellipse | 3/3 | — | | Crop Marks | Crop Marks | 1/1 | — | -| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Transform's Transform Objects/Patterns and Scale Strokes & Effects options, invisible Reflect checkboxes (#885); Zig Zag's ridges are evenly spaced along each segment (#1558); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646) | +| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Transform's Transform Objects/Patterns and Scale Strokes & Effects options, invisible Reflect checkboxes (#885); Zig Zag's ridges are evenly spaced along each segment (#1558), and Smooth points make a continuous wave whose peaks and valleys have handles along the path (#2343); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646) | | Path | Offset Path, Outline Object, Outline Stroke | 3/3 | With Use Preview Bounds on, Align, Distribute and the Transform panel still measure type that carries Outline Object by its layout box (preview bounds leave out effects) | | Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 10/13 | Hard Mix, Soft Mix, Trap | | Rasterize | Rasterize… (as a live effect) | 0/1 | the live effect (Object › Rasterize exists) | @@ -99,6 +99,7 @@ angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45 | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Zig Zag with Smooth points draws a continuous wave: every peak and valley is a smooth point with handles along the path, a third of the way to the next ridge, so each span follows a half cosine through the path instead of a straight diagonal with rounded tips, also at the ends of open paths and for ridges much taller than they are apart (#2343); score unchanged (estimated) | | 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). | | 2026-10-15 | minor | Round Corners rounds corners whose anchor carries a handle (where a curve meets a line, or two curves meet) with the round cut Live Corners uses (#1144), reaching `radius` along each side; before, they were left sharp. Corners between handle-less anchors are rounded as before; score unchanged (estimated) | | 2026-10-14 | minor | Revolve: automatic smooth vector shading, default-on Cap with continuous curved rims, and bounded seam joins; angular cut sides remain open. Scores and hour estimates unchanged at this rounding | From 4f30e523680903f750c83a5b4d5f0f521f6d3e57 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 19:56:57 +0200 Subject: [PATCH 060/208] Type: text.create and text.createInPath return the new type's bounds (#2413) Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/create.rs | 3 ++- crates/engine/src/cmd/textedit.rs | 5 +++-- crates/engine/src/cmd/typecmd.rs | 35 +++++++++++++++++++++++++++++++ crates/engine/src/inspect.rs | 2 +- docs/mcp.md | 3 ++- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index fd2486c74..23abe9389 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -74,7 +74,7 @@ pub fn specs() -> Vec { "Create Text", [], None, - "{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", + "{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", has_doc, text_create ), @@ -512,6 +512,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result { } let made = add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)?; let id = made.get("id").and_then(Value::as_u64).map(NodeId); + let made = super::typecmd::with_bounds(s, id, made); Ok(super::typecmd::with_overflow(s, id.as_slice(), made)) } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index a18de08ed..7e2cd2ec6 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Area / Path Type", [], None, - "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, overflow?: [id] (the text doesn't fit; left out when it fits)}", + "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}", has_doc, create_in_path ), @@ -609,7 +609,8 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { sel.set([id]); Ok(id) })?; - Ok(super::typecmd::with_overflow(s, &[id], json!({"id": id.0}))) + let made = super::typecmd::with_bounds(s, Some(id), json!({"id": id.0})); + Ok(super::typecmd::with_overflow(s, &[id], made)) } /// What `type.step` changes. diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 9a1b6bd11..0ebe71c12 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -269,6 +269,17 @@ pub(crate) fn with_overflow(s: &Session, ids: &[NodeId], mut result: Value) -> V result } +/// Adds `bounds` to the result of a command that made the type object `id`: the same +/// `{x, y, width, height}` box `document.inspect` reports for it (after `justify`, so a script can +/// place the new type without a helper object); `null` when it has none. +pub(crate) fn with_bounds(s: &Session, id: Option, mut result: Value) -> Value { + let Ok(st) = s.doc() else { return result }; + let symbols = st.doc.symbol_boxes(); + let b = id.and_then(|i| st.doc.node(i)).and_then(|n| n.geometric_bounds_with(&symbols)); + result["bounds"] = crate::inspect::rect_json(b); + result +} + /// The warning that Print gives for `ids`, area type and type on a path whose text doesn't fit /// (as [`with_overflow`] finds it): the text past the end of their frames doesn't print. It names /// the first 20 ids and counts the rest; `None` when `ids` is empty. @@ -783,6 +794,30 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result { mod area_tests { use super::*; + /// #2413: `text.create` and `text.createInPath` return the new type's `bounds`, the box + /// `document.inspect` reports for it, so a script can place it without a helper object. + #[test] + fn text_create_returns_the_inspect_bounds() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + let inspected = |s: &mut Session, id: &Value| { + let doc = s.execute("document.inspect", &json!({})).unwrap(); + fn find(v: &Value, id: &Value) -> Option { + if &v["id"] == id { + return Some(v["bounds"].clone()); + } + v.get("children").or_else(|| v.get("layers"))?.as_array()?.iter().find_map(|c| find(c, id)) + } + find(&doc, id).unwrap() + }; + let made = s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Hello", "size": 24, "justify": "center"})).unwrap(); + assert!(made["bounds"]["width"].as_f64().unwrap() > 10.0, "{made}"); + assert_eq!(made["bounds"], inspected(&mut s, &made["id"])); + let rect = s.execute("shape.rectangle", &json!({"x": 10, "y": 200, "width": 100, "height": 50})).unwrap(); + let area = s.execute("text.createInPath", &json!({"path": rect["id"], "mode": "area", "text": "Boxed"})).unwrap(); + assert_eq!(area["bounds"], inspected(&mut s, &area["id"])); + } + /// #1748: `text.create` takes `justify`; point type then lines up on its point (centred on /// it, or ending there), and `text.setStyle` moves it the same way afterwards. #[test] diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 29343bd74..c5fe27670 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::{ArrowAlign, Document, LineCap, LineJoin, Node, NodeKind, S use crate::Session; -fn rect_json(r: Option) -> Value { +pub(crate) fn rect_json(r: Option) -> Value { match r { Some(r) => json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }), None => Value::Null, diff --git a/docs/mcp.md b/docs/mcp.md index f77f4b2b7..5f95424d0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3165,7 +3165,8 @@ Text that doesn't fit is drawn up to the last line that does, so a one-line head draws nothing. The commands that make type or change what it holds say so in their own reply: `text.create`, `text.createInPath`, `text.setText` and `text.setStyle` add `overflow: [id…]` (the area type and type on a path among the objects they made or changed whose text doesn't fit), and the `add_text` tool adds `overflow: true`. The key is -left out when everything fits. The warnings of `file.print` and `print.preview` give the ids of the objects that print +left out when everything fits. `text.create` and `text.createInPath` also return `bounds: {x, y, width, height}`, the new type's box +as `document.inspect` reports it (after `justify`), so a script can place the type without a helper object (#2413). The warnings of `file.print` and `print.preview` give the ids of the objects that print and whose text doesn't fit: the first 20 ids, then how many more. ```json From 285a3da81a21ca781526fb8b870667f1ccf3ef87 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:12:40 +0200 Subject: [PATCH 061/208] Type: centre and right aligned lines set their tabs as if they started at the left (#2457) A tab's width depends on where it falls from the tab origin. Line breaking and the line width measure it from the left edge, but a centred or right aligned line is placed from its own start, so its tabs stopped elsewhere. The line is now laid out with its tabs measured from the left and then aligned as a whole. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 5 ++++- crates/text/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 0559730e2..5b0f200f9 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1837,6 +1837,9 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio let mut x_end = start_x; // Tab stops are measured from the frame's left edge (point type: the origin). let tab_origin = if regions.is_some() { x0 } else { 0.0 }; + // A line with tabs is measured as if it started at the left (see `set_width`), then + // aligned as a whole: its tabs stop where they were measured, however it is aligned. + let tab_shift = start_x - if regions.is_some() { ax0 } else { ind_l }; let line_start = sg.get(i).map_or(pr.start, |g| g.byte); let line_end = sg.get(end).map_or(pr.end, |g| g.byte); let order = visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, line_start..line_end, &sg[i..end]); @@ -1854,7 +1857,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio } let mut leader = ""; if g.ch == '\t' { - (adv, leader) = tab_advance(¶.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]); + (adv, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[j + 1..trimmed.max(j + 1)]); } else if j < body { if g.is_space() { adv += per_space; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 41718cf3d..03a0ec18b 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -501,6 +501,24 @@ fn a_leading_tab_takes_its_width_in_the_line_and_the_justification() { } } +#[test] +fn a_centred_or_right_aligned_line_sets_its_tabs_as_if_it_started_at_the_left() { + use vectorcraft_doc::{TabAlign, TabStop}; + // "AB" with a stop at 100 pt is 100 pt plus B wide whatever the alignment: the whole line + // moves, the gap between A and B stays 100 pt, and a right aligned line ends on the edge (#2457). + for justify in [Justify::Center, Justify::Right, Justify::JustifyCenter, Justify::JustifyRight] { + let mut t = area("A\tB", style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), justify); + t.para.tabs = vec![TabStop { position: 100.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + let lay = layout(db(), &t); + let at = |byte: usize| lay.glyphs.iter().find(|g| g.byte == byte).map(|g| g.origin.x).unwrap(); + assert!((at(2) - at(0) - 100.0).abs() < 1e-6, "{justify:?}: A at {}, B at {}", at(0), at(2)); + if matches!(justify, Justify::Right | Justify::JustifyRight) { + let b = lay.glyphs.iter().find(|g| g.byte == 2).unwrap(); + assert!((b.origin.x + b.advance - 200.0).abs() < 1e-6, "{justify:?}: the line ends at {}", b.origin.x + b.advance); + } + } +} + #[test] fn tab_leaders_line_up_from_line_to_line_and_stop_short_of_the_stop() { use vectorcraft_doc::{TabAlign, TabStop}; From 4dcd0b6caaabd5d5885ed53b7602646f1c6ed573 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 21:28:01 +0200 Subject: [PATCH 062/208] Dimension tool: show where a Linear click snaps before the click (#2516) The snap ring on an anchor (or a dot on a line) and the rubber band now follow the snapped point, so both measured points visibly land on anchors. Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/dimensions.rs | 31 +++++++++++++++++++++++ crates/tools/src/dimension.rs | 42 ++++++++++++++++++++++--------- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/crates/engine/tests/dimensions.rs b/crates/engine/tests/dimensions.rs index 05915505c..99c415a75 100644 --- a/crates/engine/tests/dimensions.rs +++ b/crates/engine/tests/dimensions.rs @@ -421,3 +421,34 @@ fn clicking_near_a_line_snaps_the_measured_point_onto_it() { let id = s.doc().unwrap().selection.objects[0].0; assert_eq!(text(&s, id), "20.00 pt"); } + +/// Both picked points snap onto anchors a few pixels away, the rubber band and a ring show the +/// snap before the click, and the dimension stays linked to the anchors (#2516). +#[test] +fn both_points_snap_onto_anchors_with_a_visible_indicator() { + use vectorcraft_engine::tools::Overlay; + let mut s = session(); + let line = edge(&mut s); + s.select_tool("dimension", ViewInfo::default()).unwrap(); + ev(&mut s, PointerKind::Move, 103.0, 102.0); + let ring = |s: &mut Session| { + s.overlays(ViewInfo::default()) + .into_iter() + .any(|o| matches!(o, Overlay::Anchor { p, size, .. } if size > 8.0 && p.x == 100.0 && p.y == 100.0)) + }; + assert!(ring(&mut s), "ring on the anchor before the first click"); + ev(&mut s, PointerKind::Down, 103.0, 102.0); + ev(&mut s, PointerKind::Up, 103.0, 102.0); + ev(&mut s, PointerKind::Move, 169.0, 98.0); + let end = s.overlays(ViewInfo::default()).into_iter().find_map(|o| if let Overlay::Line { b, .. } = o { Some(b) } else { None }).unwrap(); + assert_eq!((end.x, end.y), (172.0, 100.0), "rubber band ends on the anchor"); + ev(&mut s, PointerKind::Down, 169.0, 98.0); + ev(&mut s, PointerKind::Up, 169.0, 98.0); + ev(&mut s, PointerKind::Down, 136.0, 130.0); + ev(&mut s, PointerKind::Up, 136.0, 130.0); + let id = s.doc().unwrap().selection.objects[0].0; + assert_eq!(text(&s, id), "72.00 pt"); + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.scale", json!({"sx":200,"sy":100})); + assert_eq!(text(&s, id), "144.00 pt", "linked to both anchors"); +} diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index 2cd39dfc6..aed41c829 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -133,6 +133,23 @@ fn nearest_on(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option { (q.x.is_finite() && q.y.is_finite()).then_some(q) } +/// Where a Linear pick at `p` lands: on an anchor within reach (kept as a link to its path), +/// else on the closest point of the picked line, else on the usual snap targets or `p` itself. +fn snap_linear(cx: &ToolContext, p: Point) -> (Point, Option) { + let (mut at, _) = crate::guides::snap_pick(cx, p); + let mut reference = None; + if let Some(edge) = pick(cx, p) { + if let Some((anchor, point)) = anchor_near(cx, &edge, p) { + at = point; + reference = Some(PointRef { node: edge.node, subpath: edge.subpath, anchor }); + } else if let Some(point) = nearest_on(cx, &edge, p) { + // Not on an anchor: snap to the closest point of the line (#2432). + at = point; + } + } + (at, reference) +} + impl DimensionTool { fn target(&self, cx: &ToolContext, p: Point) -> Option { let edge = pick(cx, p)?; @@ -228,17 +245,7 @@ impl DimensionTool { fn click(&mut self, cx: &ToolContext, p: Point, target: Option, mods: Mods) -> Vec { match self.kind { Kind::Linear => { - let (mut at, _) = crate::guides::snap_pick(cx, p); - let mut reference = None; - if let Some(edge) = pick(cx, p) { - if let Some((anchor, point)) = anchor_near(cx, &edge, p) { - at = point; - reference = Some(PointRef { node: edge.node, subpath: edge.subpath, anchor }); - } else if let Some(point) = nearest_on(cx, &edge, p) { - // Not on an anchor: snap to the closest point of the line (#2432). - at = point; - } - } + let (mut at, mut reference) = snap_linear(cx, p); if mods.shift && let Some((last, _)) = self.points.last() { @@ -418,8 +425,19 @@ impl Tool for DimensionTool { for edge in &self.lines { segment(edge, &mut out); } + // Linear: show where the next click lands (anchor ring, or a dot on a line), and draw the + // rubber band to that point, so the user sees the snap before clicking (#2516). + let linear_pick = self.kind == Kind::Linear && self.pending.is_none() && self.lines.is_empty(); + let snapped = linear_pick.then(|| snap_linear(cx, self.at)); + if let Some((q, reference)) = &snapped + && (reference.is_some() || q.distance(self.at) > 1e-9) + { + let on_anchor = reference.is_some(); + out.push(Overlay::Anchor { p: *q, color, filled: !on_anchor, size: if on_anchor { 10.0 } else { 6.0 } }); + } if let Some((p, _)) = self.points.last() { - let b = if self.shift { constrained(cx, *p, self.at) } else { self.at }; + let target = snapped.map_or(self.at, |(q, _)| q); + let b = if self.shift { constrained(cx, *p, target) } else { target }; out.push(Overlay::Line { a: *p, b, color, dashed: true }); } out From 8fead43ed23fe0b04e551f9edfb071b5d43c9916 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 12:53:22 +0700 Subject: [PATCH 063/208] Tests: only the new Inner Glow test lays a background under the pattern (#2426) --- crates/engine/src/tests_pattern.rs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/crates/engine/src/tests_pattern.rs b/crates/engine/src/tests_pattern.rs index 9aeff6c70..d19e2d8c2 100644 --- a/crates/engine/src/tests_pattern.rs +++ b/crates/engine/src/tests_pattern.rs @@ -115,8 +115,6 @@ fn edit_then_cancel_restores_definition() { fn edit_defaults_to_selected_objects_pattern() { let mut s = session(); make_dots(&mut s); - let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); - s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 100.0, 100.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); sel(&mut s, &[big]); @@ -131,8 +129,6 @@ fn edit_defaults_to_selected_objects_pattern() { fn set_fill_with_pattern_swatch() { let mut s = session(); make_dots(&mut s); - let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); - s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 100.0, 100.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); assert!(matches!(node(&s, big).appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Dots")); @@ -174,8 +170,6 @@ fn options_set_tile_type_spacing_and_overlap() { fn options_rename_updates_swatch_and_paints() { let mut s = session(); make_dots(&mut s); - let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); - s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); 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("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap(); @@ -188,8 +182,6 @@ fn options_rename_updates_swatch_and_paints() { fn delete_pattern_unpaints_objects() { let mut s = session(); make_dots(&mut s); - let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); - s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let v = s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap(); @@ -203,8 +195,6 @@ fn delete_pattern_unpaints_objects() { fn transform_patterns_only_moves_placement() { let mut s = session(); make_dots(&mut s); - let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); - s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let before = node(&s, big).geometric_bounds(); @@ -222,8 +212,6 @@ fn transform_patterns_only_moves_placement() { fn transforms_without_transform_objects_move_only_the_pattern() { let mut s = session(); make_dots(&mut s); - let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); - s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); 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("select.set", &json!({"ids": [big.0]})).unwrap(); @@ -308,8 +296,6 @@ fn hit_testing_in_edit_mode_only_reaches_tile_layer() { fn save_load_roundtrip_keeps_patterns_and_repeats() { let mut s = session(); make_dots(&mut s); - let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); - s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let r = rect(&mut s, 300.0, 50.0, 10.0, 10.0); @@ -326,8 +312,6 @@ fn save_load_roundtrip_keeps_patterns_and_repeats() { fn svg_export_writes_pattern_def() { let mut s = session(); make_dots(&mut s); - let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); - s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let svg = vectorcraft_svg::export(doc(&s), &vectorcraft_svg::ExportOptions::default()); @@ -345,8 +329,6 @@ fn svg_export_writes_pattern_def() { fn pdf_export_expands_pattern_fills() { let mut s = session(); make_dots(&mut s); - let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); - s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let rep = vectorcraft_pdf::export_with_report(doc(&s), &Default::default()).unwrap(); From 0746519517493c426c77138b9bd8dd20597d5f70 Mon Sep 17 00:00:00 2001 From: EuanTop Date: Thu, 15 Oct 2026 23:51:50 +0800 Subject: [PATCH 064/208] Fix localized legacy font family name fallback --- crates/text/src/fontdb.rs | 7 ++----- crates/text/src/tests_sysfonts.rs | 32 +++++++++++++++++++++++++++++++ docs/target-app-parity.md | 5 +++-- docs/type-parity.md | 4 +++- 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 96ef04656..c37ca8e9f 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -521,11 +521,8 @@ fn pick_native_label(english: &str, pairs: &[(Option, String)]) -> Optio /// The family's native-language name from the font (typographic, else legacy), when it has one. fn native_family_name(font: &skrifa::FontRef<'_>, english: &str) -> Option { - let pairs = { - let t = localized(font, StringId::TYPOGRAPHIC_FAMILY_NAME); - if t.is_empty() { localized(font, StringId::FAMILY_NAME) } else { t } - }; - pick_native_label(english, &pairs) + pick_native_label(english, &localized(font, StringId::TYPOGRAPHIC_FAMILY_NAME)) + .or_else(|| pick_native_label(english, &localized(font, StringId::FAMILY_NAME))) } /// Every localized string of `id`. diff --git a/crates/text/src/tests_sysfonts.rs b/crates/text/src/tests_sysfonts.rs index b9b5a1400..49721a2e2 100644 --- a/crates/text/src/tests_sysfonts.rs +++ b/crates/text/src/tests_sysfonts.rs @@ -642,6 +642,38 @@ fn family_display_name_follows_show_font_names_in_english() { assert_eq!(face.family, "Hiragino Sans"); } +/// #2208: English-only typographic names must not hide a localized legacy family name. +/// Cover both fonts added as bytes and the system-font catalog before a face is loaded. +#[test] +fn family_display_name_falls_back_to_localized_legacy_names() { + const ZH: u16 = 0x0804; + for (case, (typographic, legacy, expected)) in + [(None, Some("宋体"), "宋体"), (Some("排印宋体"), Some("宋体"), "排印宋体"), (None, None, "SimSun")].into_iter().enumerate() + { + let mut records = vec![(3, EN, 1, "SimSun"), (3, EN, 2, "Regular"), (3, EN, 6, "SimSun-Regular"), (3, EN, 16, "SimSun")]; + if let Some(label) = typographic { + records.push((3, ZH, 16, label)); + } + if let Some(label) = legacy { + records.push((3, ZH, 1, label)); + } + let bytes = named(&records); + let loaded = FontDb::with_font_dirs(vec![]); + assert!(loaded.add_font(bytes.clone()) > 0); + let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-localized-family-{case}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("localized.ttf"), bytes).unwrap(); + let catalog = FontDb::with_font_dirs(vec![dir.clone()]); + for (source, db) in [("loaded", loaded), ("catalog", catalog)] { + assert_eq!(db.family_display_name("SimSun", true), "SimSun", "{source}, case {case}: English requested"); + assert_eq!(db.family_display_name("SimSun", false), expected, "{source}, case {case}: native requested"); + assert_eq!(db.family_display_name("SimSun", true), "SimSun", "{source}, case {case}: switch back to English"); + assert_eq!(db.is_loaded("SimSun"), source == "loaded", "{source}, case {case}: catalog names need no face loading"); + } + std::fs::remove_dir_all(dir).unwrap(); + } +} + /// Thai typed in a font without Thai takes an installed font made for Thai (Leelawadee UI, /// Thonburi, Noto Sans Thai…), whatever other face covering Thai loaded first; Latin beside it keeps /// the font chosen. Skipped where none of those families is installed. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9901ddb37..9162df6c9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), @@ -77,7 +77,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Live vector effects | 5 | 85% | Outline Object, Effect › Rasterize, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 44/50, depth estimated; Warp Options controls verified by focused checks and original-image review | | Raster effects (Effect Gallery) | 4 | 49% | 27 Photoshop-style filters (14 Artistic, 13 Sketch), the Effect Gallery dialog, Load Texture | 22–38 | measured count 30/57; texture redraw distances optimized (#1257), pixels verified; depth estimated | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (other types use flat shading; one light, painter visibility); materials, several lights, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | -| Type core | 9 | 80% | Auto-leading style resets are implemented; remaining: hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass | +| Type core | 9 | 80% | Auto-leading style resets are implemented; remaining: hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration and Export Type (Graphic / Movie Clip) are done | 5–9 | estimated | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | @@ -690,6 +690,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Type: localized legacy family names remain available when typographic names are English-only (#2208); loaded and catalog paths tested. Scores and estimates unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-16 | minor | Align Stroke on live type (beyond Illustrator): the characters' stroke goes inside or outside the glyphs without creating outlines (#2294); scores and hours unchanged | | 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). | diff --git a/docs/type-parity.md b/docs/type-parity.md index ae3af354d..beb684425 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (native family labels fall back to localized legacy names when typographic names are English-only, #2208; scores unchanged; Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -18,6 +18,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Feature | Status | Notes | |---|---|---| +| Native font-family labels | partial | With Show Font Names in English off, localized legacy names are used when typographic names contain no native label (#2208); loaded-font and catalog-only regressions. Choosing among multiple native languages is unchanged. | | Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802); `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands | | Area Type Options (rows, columns, inset, first baseline, Auto Size, Shrink Text to Fit) | done | the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) | | Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444) | @@ -59,6 +60,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Native font-family labels fall back from English-only typographic names to localized legacy names (#2208). English labels and native typographic precedence are preserved; synthetic loaded/catalog tests. Scores and estimates unchanged. | | 2026-10-16 | minor | Align Stroke on live type: the characters' stroke goes inside or outside the glyphs without creating outlines, for every run or a range; kept in the native file and in canvas, PDF, EPS and SVG output (#2294); beyond Illustrator, scores and hours unchanged | | 2026-10-14 | minor | Cmd+Shift+C (Align center) works on Windows and Linux too, where egui reported it as a copy (#1940); scores and hours unchanged | | 2026-10-14 | minor | Cmd+Shift+C (Align center) works on Windows and Linux too, where egui reported it as a copy; a double-click on area type's bottom middle handle turns on Auto Size (#1940); scores and hours unchanged | From d5561d6e02bea8d98bea7e3ab36942d97fac5f07 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 12:54:10 +0700 Subject: [PATCH 065/208] Live Corners: drop the single-corner radius limits nothing uses since #2332 --- crates/geom/src/corners.rs | 9 --------- crates/geom/src/shapes.rs | 8 -------- 2 files changed, 17 deletions(-) diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index 16120aa0e..46449f43a 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -148,12 +148,6 @@ impl Corner { self.radius_at_setback(room(self.lu, before).min(room(self.lv, after))) } - /// The radius the corner is drawn with whatever its neighbours do: `radius`, no larger than - /// [`Corner::max_radius`], 0 when negative or not a number. - pub fn fitted(&self, radius: f64) -> f64 { - radius.max(0.0).min(self.max_radius()) - } - /// The largest radius the corner draws when it and the corners for which `edited` holds are /// set to one radius together, the other corners of `corners` keeping their `radii` (by /// anchor index). A side shared with an edited corner takes both cuts as far as they fit @@ -422,9 +416,6 @@ mod tests { for c in &corners { let max = c.max_radius(); assert!((c.setback(max) - c.lu.min(c.lv) / 2.0).abs() < 1e-9); - assert_eq!(c.fitted(1e6), max); - assert_eq!(c.fitted(-3.0), 0.0); - assert_eq!(c.fitted(f64::NAN), 0.0); } // The right angle at (0, 0) takes up to 20, half its shorter side. assert!((corners[0].max_radius() - 20.0).abs() < 1e-9); diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs index 54a7ad933..e677d34ff 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -52,13 +52,6 @@ pub fn max_corner_radius(w: f64, h: f64) -> f64 { w.abs().min(h.abs()) / 2.0 } -/// The radius a corner of a `w` × `h` rectangle is drawn with: `radius`, no larger than -/// [`max_corner_radius`] (the same limit for every corner, so opposite corners match), 0 when -/// negative or not a number. -pub fn fitted_corner_radius(w: f64, h: f64, radius: f64) -> f64 { - radius.max(0.0).min(max_corner_radius(w, h)) -} - /// The radii the corners of a `w` × `h` rectangle (top-left, top-right, bottom-right, /// bottom-left) are drawn with: each its own, no larger than leaves room for its neighbours' /// cuts ([`crate::corners::fitted_radii`]): half the shorter side when they are all as large, up @@ -409,7 +402,6 @@ mod tests { fn corners_of_a_non_square_rectangle_are_alike() { let r = Rect::new(0.0, 0.0, 200.0, 80.0); assert_eq!(max_corner_radius(-200.0, 80.0), 40.0); - assert_eq!([30.0, 60.0, -1.0, f64::NAN].map(|x| fitted_corner_radius(200.0, 80.0, x)), [30.0, 40.0, 0.0, 0.0]); for (radius, drawn) in [(30.0, 30.0), (60.0, 40.0)] { for kinds in [[CornerKind::Round; 4], [CornerKind::Chamfer, CornerKind::InvertedRound, CornerKind::Round, CornerKind::Chamfer]] { let a: Vec = rectangle_with_corners(r, [radius; 4], kinds).subpaths[0].anchors.iter().map(|a| a.p).collect(); From 4ed644d98d028fdb1dd64549968b02a6cdd48f26 Mon Sep 17 00:00:00 2001 From: Roman Vasilyev Date: Fri, 16 Oct 2026 16:58:07 -0700 Subject: [PATCH 066/208] EPS: preserve editable Illustrator art and filter raster page comparison --- contributors/people.toml | 3 + crates/eps/src/import/ai/mesh.rs | 20 ++++- crates/eps/src/import/ai/obj.rs | 2 + crates/eps/src/import/ai/read.rs | 91 ++++++++++++++++++--- crates/eps/src/import/ai/tests.rs | 75 +++++++++++++++++- crates/eps/src/import/native.rs | 109 ++++++++++++++++++++++++-- crates/eps/src/import/tests_native.rs | 48 ++++++++++++ docs/file-format-parity.md | 13 ++- docs/gaps.md | 5 +- docs/roadmap.md | 7 +- docs/target-app-parity.md | 5 +- 11 files changed, 347 insertions(+), 31 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index b30eb0fd2..0716520f9 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -120,3 +120,6 @@ sync_github_name = false real_name = "Marco Arnulfo" display_name = "Marco Arnulfo" sync_github_name = false + +[people.theromis] +sync_github_name = true diff --git a/crates/eps/src/import/ai/mesh.rs b/crates/eps/src/import/ai/mesh.rs index 136818a4e..4866ffad1 100644 --- a/crates/eps/src/import/ai/mesh.rs +++ b/crates/eps/src/import/ai/mesh.rs @@ -96,8 +96,24 @@ impl MeshReader { let [n0, n1, n2, n3] = corners else { return None }; // (anchor, towards the next corner, towards the previous one, colour, opacity) let read = |n: &Vec| -> Option<(Point, Vec2, Vec2, Color, f32)> { - let comps = n.len().checked_sub(9)?; - let &[opacity, x, y, nx, ny, _, px, py, _] = n.get(comps..)? else { return None }; + // Older editing streams omit opacity; the colour space determines the number + // of colour components, so a coordinate must never be mistaken for opacity. + let comps = match self.space.as_str() { + "DeviceRGB" => 3, + "DeviceCMYK" => 4, + "DeviceGray" => 1, + _ => return None, + }; + let (opacity, geometry) = match n.get(comps..)? { + geometry if geometry.len() == 8 => (1.0, geometry), + [opacity, geometry @ ..] if geometry.len() == 8 => (*opacity, geometry), + _ => return None, + }; + let &[x, y, nx, ny, nw, px, py, pw] = geometry else { return None }; + // Non-unit rational weights need a different surface representation. + if nw != 1.0 || pw != 1.0 { + return None; + } if ![opacity, x, y, nx, ny, px, py].iter().all(|v| v.is_finite()) { return None; } diff --git a/crates/eps/src/import/ai/obj.rs b/crates/eps/src/import/ai/obj.rs index a6ae43c8a..d91f153a8 100644 --- a/crates/eps/src/import/ai/obj.rs +++ b/crates/eps/src/import/ai/obj.rs @@ -66,6 +66,8 @@ pub(super) struct Obj { pub entries: Vec<(Option, Vec)>, /// Values closed without a key. pub content: Vec, + /// Vector art enclosed by `X=` … `X+` in an opacity-mask dictionary. + pub art: Option>, } impl Obj { diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index a4fc4eac2..95cad73c7 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -272,6 +272,7 @@ enum Kind { Group, Compound, Clip, + Mask, Obj(Box), } @@ -290,6 +291,8 @@ struct Frame { base: usize, /// The clipping paths' indexes among the children. clips: Vec, + /// A compound member group explicitly declares its subpaths' winding with `D`. + compound_members: bool, } /// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands @@ -383,6 +386,8 @@ struct Reader<'a> { clip_fills: HashSet, /// Groups closed as an object's drawn look (`9 () XW`). looks: HashSet, + /// Legacy compound member groups, even when followed by the generic `9 () XW` marker. + compound_groups: HashSet, /// The gradient mesh being read (`/Mesh X!` … `/End X!`), and whether it started on a `%_` line. mesh: Option<(super::mesh::MeshReader, bool)>, /// The art styles (`%AI9_BeginArtStyles`), by name. @@ -425,9 +430,6 @@ const IGNORED: &[&str] = &[ "Xh", "XH", "XF", - "D", - "X=", - "X+", "Bc", "XP", "Np", @@ -529,6 +531,7 @@ impl<'a> Reader<'a> { hidden_ids: HashSet::new(), clip_fills: HashSet::new(), looks: HashSet::new(), + compound_groups: HashSet::new(), group_ended: false, layer_state: None, styles: HashMap::new(), @@ -785,7 +788,16 @@ impl<'a> Reader<'a> { self.ensure_space(); } let base = if matches!(kind, Kind::Obj(_)) { self.stack.len() } else { self.frames.last().map_or(0, |f| f.base) }; - self.frames.push(Frame { kind, children: Vec::new(), hidden, after: self.after_object, gs: self.gs.clone(), base, clips: Vec::new() }); + self.frames.push(Frame { + kind, + children: Vec::new(), + hidden, + after: self.after_object, + gs: self.gs.clone(), + base, + clips: Vec::new(), + compound_members: false, + }); self.after_object = false; Ok(()) } @@ -836,7 +848,7 @@ impl<'a> Reader<'a> { "*U" => self.close(what, |k| matches!(k, Kind::Compound))?, _ => self.close(what, |k| matches!(k, Kind::Clip))?, }; - let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, .. } = f; + let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, compound_members, .. } = f; // A group holding only clipping paths (`q … u W n U … Q`) doesn't clip on its own: `u`/`U` don't save // the graphics state as `q`/`Q` do, so its paths clip the clipping group around it, as the file's PDF part draws it. if matches!(kind, Kind::Group) @@ -879,6 +891,9 @@ impl<'a> Reader<'a> { if clipping && let Some(f) = self.frames.last_mut() { f.clips.push(f.children.len()); } + if compound_members { + self.compound_groups.insert(node.id); + } self.add(node, opened_hidden || hidden)?; if let Some(p) = painted { self.clip_fills.insert(p.id); @@ -994,6 +1009,16 @@ impl<'a> Reader<'a> { return Ok(()); } ";" => return self.end_obj(), + "X=" => { + if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(o), .. }) if o.ty == "Mask") { + // `X=` also introduces legacy text frame art, read by `text_frames`. + return Ok(()); + } + self.end_path_quietly(); + return self.open(Kind::Mask, hidden); + } + "X+" if self.frames.iter().any(|f| matches!(f.kind, Kind::Mask)) => return self.end_mask(), + "X+" => return Ok(()), _ => {} } // What follows an object (its name, transparency and style) ends with anything else. The @@ -1105,6 +1130,19 @@ impl<'a> Reader<'a> { } } "d" => self.dash(&vals), + "D" => { + // The published format §6.8 permits `u` groups of compound members: + // https://www.moon-soft.com/program/FORMAT/graphics/ai30.pdf + // Their explicit winding declarations describe subpaths, not separate paints; + // modern streams can also end these groups with `9 () XW`. + let n = self.frames.len(); + if n >= 2 + && self.frames.get(n - 2).is_some_and(|f| matches!(f.kind, Kind::Compound)) + && let Some(f) = self.frames.last_mut().filter(|f| matches!(f.kind, Kind::Group)) + { + f.compound_members = true; + } + } // Transparency and state. "Xy" => self.transparency(&vals), // A group's own state (`Xd`, `6 () XW`) without an `Xy` keeps the current transparency, as @@ -1224,6 +1262,23 @@ impl<'a> Reader<'a> { Ok(()) } + /// Capture mask art in document coordinates, without adding it to the owner's siblings. + fn end_mask(&mut self) -> Result<(), String> { + self.end_path_quietly(); + let f = self.close("X+", |k| matches!(k, Kind::Mask))?; + let (children, clip) = self.clip_first(f.children, &f.clips); + let art = self.new_node(NodeKind::Group { children, clip }, &f.gs); + self.gs = f.gs; + self.after_object = f.after; + let Some(Frame { kind: Kind::Obj(o), .. }) = self.frames.last_mut() else { + return Err("mask art ends outside a mask dictionary".into()); + }; + if o.art.replace(Arc::new(art)).is_some() { + return Err("a mask has more than one art block".into()); + } + Ok(()) + } + /// `;`: the innermost dictionary closes. fn end_obj(&mut self) -> Result<(), String> { if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) { @@ -1379,13 +1434,19 @@ impl<'a> Reader<'a> { /// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), and the /// opacity mask it may carry. fn art_dictionary(&mut self, o: &Obj) { - // A mask (`/Mask`, keyed `AI9_Mask`): its art is on `X=` … `X+` lines, and drawing the - // object without it shows what the mask hides, so it must be named (#2004). A mask that - // is disabled changes nothing. - if let Some(mask) = o.obj("AI9_Mask").or_else(|| o.obj("Mask")) - && mask.nums("Disabled").first().copied() != Some(1.0) - { - self.unreadable("opacity masks"); + if let Some(mask) = o.obj("AI9_Mask").or_else(|| o.obj("Mask")) { + if let Some(art) = &mask.art { + let on = |key: &str| mask.nums(key).first().copied() == Some(1.0); + let value = + OpacityMask { art: art.clone(), clip: on("Clipping"), invert: on("Inverted"), disabled: on("Disabled"), linked: on("Linked") }; + if let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })) { + n.extras_mut().mask = Some(Box::new(value)); + } else if !on("Disabled") { + self.unreadable("opacity masks without an owning object"); + } + } else if mask.nums("Disabled").first().copied() != Some(1.0) { + self.unreadable("opacity masks without readable art"); + } } let name = match o.text("AIArtName") { Some(name) => name, @@ -1542,7 +1603,11 @@ impl<'a> Reader<'a> { let style = vals.iter().rev().find_map(V::text).unwrap_or_default(); let after = std::mem::take(&mut self.after_object); if code == 9.0 - && let Some(look) = self.frames.last().and_then(|f| f.children.last()).filter(|n| matches!(n.kind, NodeKind::Group { .. })) + && let Some(look) = self + .frames + .last() + .and_then(|f| f.children.last()) + .filter(|n| matches!(n.kind, NodeKind::Group { .. }) && !self.compound_groups.contains(&n.id)) { self.looks.insert(look.id); } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index d903e4d05..404c216db 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -809,8 +809,7 @@ fn what_it_doesnt_read_is_an_error_where_it_shows() { assert!(e.contains("pattern fills"), "{e}"); let e = read(stream(&layer("/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n")).as_bytes()).unwrap_err(); assert!(e.contains("symbols"), "{e}"); - // An opacity mask (#2004): drawing the layer's art whole would show what the mask hides, so - // the mask is named and the file is read from its PDF part instead. + // A mask with no owner must still be reported (#2004). let mask = "%_/ArtDictionary :\n%_/XMLUID : (M) ; (AI10_ArtUID) ,\n%_/Mask :\n%_1 /Bool (Linked) ,\n%_0 /Bool (Disabled) ,\n%_X=\n\ %_0 0 0 1 k\n%_0 0 m\n%_5 0 L\n%_5 5 L\n%_0 5 L\n%_0 0 L\n%_f\n%_X+\n%_ (Art) ,\n%_1 /Bool (Clipping) ,\n%_0 /Bool (Inverted) ,\n%_; (AI9_Mask) ,\n%_;\n"; let e = read(stream(&layer(mask)).as_bytes()).unwrap_err(); @@ -1219,3 +1218,75 @@ fn a_gradient_mesh_reads_as_a_patch() { let e = read(stream(&layer(&mesh("Separation"))).as_bytes()).unwrap_err(); assert!(e.contains("gradient meshes"), "{e}"); } + +/// Both mesh-corner layouts must keep colour, position and handles, rather than guessing +/// opacity from a coordinate. Unsupported weights and truncated corners remain errors. +#[test] +fn mesh_corners_with_and_without_opacity() { + let geometry = ["10 20 10 25 1 15 20 1", "10 50 15 50 1 10 45 1", "40 50 40 45 1 35 50 1", "40 20 35 20 1 40 25 1"]; + for (space, colour) in [("DeviceRGB", "0.2 0.4 0.6"), ("DeviceCMYK", "0.1 0.2 0.3 0.4"), ("DeviceGray", "0.5")] { + let mesh = |opacity: &str| { + let corners: String = geometry.iter().map(|g| format!("%_[{colour} {opacity}{g}] /N X#\n")).collect(); + format!("/Mesh X!\n%_/{space} /CS X#\n{corners}%_/E X#\n/End X!\n") + }; + let old = art(&read_ok(&layer(&mesh(""))).doc); + let new = art(&read_ok(&layer(&mesh("1 "))).doc); + assert_eq!(old[0].kind, new[0].kind); + let translucent = art(&read_ok(&layer(&mesh("0.25 "))).doc); + let NodeKind::Mesh(m) = &translucent[0].kind else { panic!("mesh") }; + assert!(m.points.iter().all(|p| p.opacity == 0.25)); + assert_eq!(m.points[0].p, Point::new(10.0, 80.0)); + for broken in [mesh("").replace("10 25 1", "10 25 0.5"), mesh("").replace("15 20 1]", "15 20]")] { + assert!(read(stream(&layer(&broken)).as_bytes()).unwrap_err().contains("gradient meshes")); + } + } +} + +#[test] +fn vector_opacity_mask_keeps_its_art_and_does_not_paint_as_a_sibling() { + let owner = format!("{RED}{}f\n", rect_path(10.0, 20.0, 50.0, 60.0)); + let mask = "%_/ArtDictionary :\n%_/Mask :\n%_1 /Bool (Linked) ,\n%_X=\n%_u\n%_1 g\n%_10 20 m\n%_10 60 L\n%_30 60 L\n%_30 20 L\n%_f\n%_U\n%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_0 /Bool (Inverted) ,\n%_0 /Bool (Disabled) ,\n%_; (AI9_Mask) ,\n%_;\n"; + let following = format!("{}f\n", rect_path(70.0, 20.0, 80.0, 30.0)); + for (invert, disabled, linked) in [(false, false, true), (true, false, false), (false, true, true)] { + let mask = mask + .replace("0 /Bool (Inverted)", &format!("{} /Bool (Inverted)", u8::from(invert))) + .replace("0 /Bool (Disabled)", &format!("{} /Bool (Disabled)", u8::from(disabled))) + .replace("1 /Bool (Linked)", &format!("{} /Bool (Linked)", u8::from(linked))); + let r = read_ok(&layer(&format!("{owner}{mask}{following}"))); + assert!(r.warnings.is_empty()); + let a = art(&r.doc); + assert_eq!(a.len(), 2); + assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)), "mask colour must not leak"); + let m = a[0].extras().mask.as_ref().unwrap(); + assert_eq!((m.clip, m.invert, m.disabled, m.linked), (true, invert, disabled, linked)); + let mask_path = &m.art.children().unwrap()[0].children().unwrap()[0]; + assert!(matches!(mask_path.kind, NodeKind::Path { .. })); + assert_eq!(mask_path.geometric_bounds(), Some(Rect::new(10.0, 40.0, 30.0, 80.0))); + assert_ne!(mask_path.id, a[0].id); + } + for broken in [mask.replace("%_X+\n", ""), mask.replace("%_U\n", ""), mask.replace("%_f", "%_unknownMaskOperator")] { + assert!(read(stream(&layer(&format!("{owner}{broken}"))).as_bytes()).is_err()); + } +} + +/// `9 () XW` also follows legacy compound member groups. The final gradient paints +/// every member; the initial black paint is a placeholder, not a separate black object. +#[test] +fn winding_declared_compound_members_keep_the_final_gradient() { + let defs = "%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n0 0 0 0 1 0 0 2 50 0 Bs\n0 0 0 0 0 0 1 2 50 100 Bs\nBD\n%AI5_EndGradient\n"; + let body = format!( + "*u\nu\n1 D\n0 g\n{}f\n0 D\n{}Bb\n1 (G) 0 0 0 100 1 0 0 1 0 0 Bg\nf\n0 BB\nU\n9 () XW\n*U\n", + rect_path(10.0, 10.0, 80.0, 80.0), + rect_path(30.0, 30.0, 50.0, 50.0) + ); + let r = read_ok(&format!("{defs}{}", layer(&body))); + let a = art(&r.doc); + assert_eq!(a.len(), 1); + let NodeKind::Compound { children, .. } = &a[0].kind else { panic!("compound, not separately painted look") }; + assert_eq!(children.len(), 2); + assert!(matches!(fill(&a[0]), Paint::Gradient(g) if g.gradient.stops.len() == 2)); + assert!(children.iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); + // Without winding declarations this is still an independently painted appearance group. + let r = read_ok(&format!("{defs}{}", layer(&body.replace("1 D\n", "").replace("0 D\n", "")))); + assert!(matches!(art(&r.doc)[0].kind, NodeKind::Group { .. })); +} diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 465cec773..4564ff1ad 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -703,6 +703,44 @@ fn compare_scale(r: Rect) -> f64 { (400.0 / r.width().max(r.height()).max(1.0)).min(4.0) } +/// A flattened print page made only of images. Sampling it directly at 400 pixels aliases +/// its fine edges while vector art receives coverage antialiasing. Compare both at a higher +/// resolution and apply the same reconstruction filter; keep the existing difference limits. +fn comparison_samples(doc: &Document) -> u32 { + let (mut images, mut vector) = (false, false); + doc.walk(|n| match n.kind { + NodeKind::Image(_) => images = true, + NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Path { clipping: true, .. } => {} + _ => vector = true, + }); + if images && !vector { 4 } else { 1 } +} + +/// Render for the page check. The comparison scale bounds the long side to 400 px, so the +/// extra samples stay bounded to 1600 px. Filter premultiplied channels, including alpha. +fn comparison_render( + renderer: &mut vectorcraft_render::Renderer, + doc: &Document, + rect: Rect, + scale: f64, + samples: u32, +) -> vectorcraft_render::Rendered { + let rendered = renderer.render_region(doc, rect, scale * f64::from(samples), false); + if samples == 1 { + return rendered; + } + let (width, height) = match vectorcraft_render::raster_size(rect, scale) { + Ok(size) => size, + // A malformed region cannot provide a comparable raster. + Err(_) => return rendered, + }; + let Some(src) = image::RgbaImage::from_raw(rendered.width, rendered.height, rendered.pixels) else { + return vectorcraft_render::Rendered { width: 0, height: 0, pixels: Vec::new() }; + }; + let pixels = image::imageops::resize(&src, width, height, image::imageops::FilterType::Triangle).into_raw(); + vectorcraft_render::Rendered { width, height, pixels } +} + /// The page drawn once, to weigh the layers' art against it: [`Self::difference`] draws the layers /// with a renderer of their own, which keeps their decoded images between draws (the page's images /// can share their keys and not their pixels, so the two don't share one). @@ -712,6 +750,7 @@ struct PageView { page_type: Vec, layers: vectorcraft_render::Renderer, scale: f64, + samples: u32, } impl PageView { @@ -720,10 +759,11 @@ impl PageView { let scale = compare_scale(ra); // Drawn on nothing and then seen on white: blending modes have no page to blend with, as in // the app that wrote the file. - let page = vectorcraft_render::Renderer::new().render_region(frame.page, ra, scale, false); + let samples = comparison_samples(frame.page); + let page = comparison_render(&mut vectorcraft_render::Renderer::new(), frame.page, ra, scale, samples); let mut page_type = vec![]; type_areas(&frame.page.layers, ra, scale, &mut page_type); - Self { page, page_type, layers: vectorcraft_render::Renderer::new(), scale } + Self { page, page_type, layers: vectorcraft_render::Renderer::new(), scale, samples } } /// How `b` on `rb` (a rectangle the page's size) draws differently from the page ([`compared`]). @@ -731,7 +771,7 @@ impl PageView { // The page doesn't print what isn't printed. let printed = as_printed(b); let b = printed.as_ref().unwrap_or(b); - let y = self.layers.render_region(b, rb, self.scale, false); + let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); let mut areas = self.page_type.clone(); type_areas(&b.layers, rb, self.scale, &mut areas); compared(&self.page, &y, &areas) @@ -743,7 +783,7 @@ impl PageView { fn colour_difference(&mut self, b: &Document, rb: Rect) -> f64 { let printed = as_printed(b); let b = printed.as_ref().unwrap_or(b); - let y = self.layers.render_region(b, rb, self.scale, false); + let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); if (y.width, y.height) != (self.page.width, self.page.height) { return 1.0; } @@ -759,8 +799,9 @@ fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference { let b = printed.as_ref().unwrap_or(b); let scale = compare_scale(ra); // A renderer each: the two documents' images can share their keys and not their pixels. - let x = vectorcraft_render::Renderer::new().render_region(a, ra, scale, false); - let y = vectorcraft_render::Renderer::new().render_region(b, rb, scale, false); + let samples = comparison_samples(a); + let x = comparison_render(&mut vectorcraft_render::Renderer::new(), a, ra, scale, samples); + let y = comparison_render(&mut vectorcraft_render::Renderer::new(), b, rb, scale, samples); let mut areas = vec![]; type_areas(&a.layers, ra, scale, &mut areas); type_areas(&b.layers, rb, scale, &mut areas); @@ -772,7 +813,7 @@ fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference { /// at all ([`differ`]): the page of an RGB document is written in the colours' CMYK equivalents, /// and what the comparison looks for is art that is missing or out of place. fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect]) -> Difference { - if x.width != y.width || x.height != y.height { + if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height { return Difference { any: 1.0, solid: 1.0 }; } let differs: Vec = x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).map(|(p, q)| differ(p, q)).collect(); @@ -1358,4 +1399,58 @@ mod tests { let texts: Vec = parts.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect(); assert_eq!(texts, ["Hello", "abcd"]); } + /// A flattened page samples a fine stripe pattern once per comparison pixel; that + /// aliases to white although vector coverage is gray. Shared minification avoids the + /// false fallback, while genuinely missing art must still be rejected. + #[test] + fn raster_page_comparison_filters_minification_without_accepting_missing_art() { + use vectorcraft_color::{Color, Paint}; + use vectorcraft_doc::{Appearance, ImageBlob, ImageObject}; + let mut vector = Document::new(1000.0, 500.0); + let layer = vector.layers[0].id; + for i in 0..400 { + let x = f64::from(i) * 2.5 + 0.5; + let n = Node::path( + vector.alloc_id(), + vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 1.0, 500.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + vector.insert(Some(layer), usize::MAX, n).unwrap(); + } + let rect = vector.artboards[0].rect; + let source = vectorcraft_render::Renderer::new().render_region(&vector, rect, 1.6, true); + let mut png = Vec::new(); + image::RgbaImage::from_raw(source.width, source.height, source.to_straight()) + .unwrap() + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + let mut page = Document::new(1000.0, 500.0); + page.images.insert(key.clone(), blob); + let image = Node::new( + page.alloc_id(), + NodeKind::Image(Box::new(ImageObject { + key, + width: source.width, + height: source.height, + xf: Affine::scale(1.0 / 1.6), + link: None, + placement: Default::default(), + })), + ); + page.insert(Some(page.layers[0].id), usize::MAX, image).unwrap(); + assert_eq!(comparison_samples(&page), 4); + assert_eq!(comparison_samples(&vector), 1, "ordinary vector pages retain their comparison"); + let mut renderer = vectorcraft_render::Renderer::new(); + let a = comparison_render(&mut renderer, &page, rect, 0.4, 1); + let b = comparison_render(&mut vectorcraft_render::Renderer::new(), &vector, rect, 0.4, 1); + assert!(compared(&a, &b, &[]).solid > MAX_SOLID_DIFFERENCE, "the old point sampling falsely refused this page"); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false }; + let mut view = PageView::new(&frame); + assert!(compare(&frame, &mut view, &vector).is_ok()); + assert!(compare(&frame, &mut view, &Document::new(1000.0, 500.0)).is_err(), "missing artwork remains an error"); + let shifted = Rect::new(0.0, 0.0, 500.0, 500.0); + assert!(view.difference(&vector, shifted).any > MAX_DIFFERENCE, "wrong framing remains an error"); + } } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index b7f9a67fe..7406ca3ee 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -994,3 +994,51 @@ fn a_copied_area_type_object_is_framed_where_its_own_art_path_is() { let b = frame.bounds().unwrap(); assert!((b.x0 - 30.0).abs() < 1e-6 && (b.y0 - 30.0).abs() < 1e-6 && (b.x1 - 80.0).abs() < 1e-6 && (b.y1 - 50.0).abs() < 1e-6, "{b:?}"); } + +/// A raster print page can carry a fully vector editing copy. The legacy mesh has no +/// opacity operand and its vector mask hides half of it: ignoring either loses this import. +#[test] +fn raster_print_page_recovers_legacy_mesh_vector_mask_and_editable_curve() { + let mesh = "/Mesh X!\n%_/DeviceRGB /CS X#\n\ + %_[0 0 1 20 20 20 30 1 30 20 1] /N X#\n\ + %_[0 0 1 20 60 30 60 1 20 50 1] /N X#\n\ + %_[0 0 1 60 60 60 50 1 50 60 1] /N X#\n\ + %_[0 0 1 60 20 50 20 1 60 30 1] /N X#\n%_/E X#\n/End X!\n"; + let mask = "%_/ArtDictionary :\n%_/Mask :\n%_X=\n%_1 g\n%_20 20 m\n%_20 60 L\n%_40 60 L\n%_40 20 L\n%_f\n%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n"; + let curve = "0 1 1 0 1 0 0 Xa\n70 20 m\n70 50 90 50 90 20 c\nf\n"; + let data = native(&layer("Editable", true, &format!("u\n{mesh}{mask}{curve}U\n"))); + let samples = ("0000ff".repeat(20) + &"ffffff".repeat(20)).repeat(40); + let page = format!( + "gsave 20 20 translate 40 40 scale /DeviceRGB setcolorspace \ + << /ImageType 1 /Width 40 /Height 40 /BitsPerComponent 8 /Decode [0 1 0 1 0 1] \ + /ImageMatrix [40 0 0 -40 0 40] /DataSource <{samples}> >> image grestore \ + 1 0 0 setrgbcolor 70 20 moveto 70 50 90 50 90 20 curveto closepath fill" + ); + // Use AI9's zlib container, too, rather than only the newer Zstandard test helper. + use std::io::Write as _; + let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(data.as_bytes()).unwrap(); + let packed = ascii85(&encoder.finish().unwrap()); + let file = format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{page}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI9_DataStream\n%{packed}\n%AI9_PrivateDataEnd\n" + ); + let r = import(file.as_bytes()).unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert_eq!(names(&r.document), ["Editable"]); + let group = &r.document.layers[0].children().unwrap()[0]; + let a = group.children().unwrap(); + assert_eq!(a.len(), 2); + assert!(matches!(a[0].kind, NodeKind::Mesh(_))); + assert!(a[0].extras().mask.is_some()); + let NodeKind::Path { path, .. } = &a[1].kind else { panic!("editable path") }; + assert_eq!(path.anchor_count(), 2); + assert_ne!(path.subpaths[0].anchors[0].h_out, path.subpaths[0].anchors[0].p); + assert_ne!(a[0].id, a[1].id); + // The normal EPS path remains available explicitly. + let page = crate::import_with(file.as_bytes(), false).unwrap(); + assert!(matches!(page.document.layers[0].children().unwrap()[0].kind, NodeKind::Image(_))); + // A wrong editing copy must still fail the page comparison. + let wrong = file.replace("%%BoundingBox: 0 0 100 100", "%%BoundingBox: 0 0 120 100"); + let r = import(wrong.as_bytes()).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("different page")), "{:?}", r.warnings); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d0010471b..7b6fb8b40 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (batch exporting, #1935; legacy `.ai` runs as PostScript with a prolog of its own, #868; legacy `.ai` of version 7 saved, with type as text, hidden objects and an artboard picker, and its type read, #868; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: SVG `:not()` selectors, #1557, stacked selectors, #1703, linked files, #1870; DXF VPORT, #1789; nesting caps, #1852; hostile PDF, `.ai`, EPS, Affinity and native data, #1862, #1863, #1871–#1873; failed saves, #1685, #1693, #1694; type in symbols after reopening, #1738; scores unchanged; verified PngSuite downloads, #1729; EPS stitched shadings, #1653; oversized images in WMF/EMF refused from the header, #1776; PDF name trees with shared nodes, #1781; native size limits for files read from memory, #1783; private temporary files on Unix, #1778; ruzstd 0.9.1, #1807; scores unchanged; SVG import: CSS transforms and the `font` shorthand, #1565, percentage lengths in symbols, #1596, Inkscape flowed text and pages, #1568, #1601, filter warnings, #1598; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment in JPEG, TIFF and PSD, #1584; saves to folders that refuse the temporary file, #1620 in part; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; compact native saves for documents with a lot of path data, #1492; PDF and EPS paste as vector art, #1499; `.ai` gradient stops listed upwards, #1530; SVG text on basic shapes, #1527; PDF soft masks drawn as images, #1551; Type 42 fonts in EPS, #1553; clips that only seem to cover the page, #1542; images too large to decode are reported, #1540; a `.ai` page's art past its artboard no longer lies over the page before it when read as its PDF part; grayscale TIFFs with an alpha channel or extra channels place and open, #1466 in part; a faded or masked fill keeps its stroke apart on PDF and `.ai` import, #1434; SVG knockout groups without masks, #1435; Affinity import: a text frame too short for its first line is made taller, #1423; `.ai` editing data: a compound path takes its last path's paint and keeps paths grouped inside it, #1410; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; `editingData: "always"` opens a `.ai` from its editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; SVG knockout groups of opaque art without masks; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` keeps its RGB numbers; PDF import keeps a stroke apart from a fill masked or faded by its group) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; batch exporting, #1935; legacy `.ai` runs as PostScript with a prolog of its own, #868; legacy `.ai` of version 7 saved, with type as text, hidden objects and an artboard picker, and its type read, #868; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: SVG `:not()` selectors, #1557, stacked selectors, #1703, linked files, #1870; DXF VPORT, #1789; nesting caps, #1852; hostile PDF, `.ai`, EPS, Affinity and native data, #1862, #1863, #1871–#1873; failed saves, #1685, #1693, #1694; type in symbols after reopening, #1738; scores unchanged; verified PngSuite downloads, #1729; EPS stitched shadings, #1653; oversized images in WMF/EMF refused from the header, #1776; PDF name trees with shared nodes, #1781; native size limits for files read from memory, #1783; private temporary files on Unix, #1778; ruzstd 0.9.1, #1807; scores unchanged; SVG import: CSS transforms and the `font` shorthand, #1565, percentage lengths in symbols, #1596, Inkscape flowed text and pages, #1568, #1601, filter warnings, #1598; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment in JPEG, TIFF and PSD, #1584; saves to folders that refuse the temporary file, #1620 in part; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; compact native saves for documents with a lot of path data, #1492; PDF and EPS paste as vector art, #1499; `.ai` gradient stops listed upwards, #1530; SVG text on basic shapes, #1527; PDF soft masks drawn as images, #1551; Type 42 fonts in EPS, #1553; clips that only seem to cover the page, #1542; images too large to decode are reported, #1540; a `.ai` page's art past its artboard no longer lies over the page before it when read as its PDF part; grayscale TIFFs with an alpha channel or extra channels place and open, #1466 in part; a faded or masked fill keeps its stroke apart on PDF and `.ai` import, #1434; SVG knockout groups without masks, #1435; Affinity import: a text frame too short for its first line is made taller, #1423; `.ai` editing data: a compound path takes its last path's paint and keeps paths grouped inside it, #1410; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; `editingData: "always"` opens a `.ai` from its editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; SVG knockout groups of opaque art without masks; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` keeps its RGB numbers; PDF import keeps a stroke apart from a fill masked or faded by its group) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/mod.rs`). Illustrator's list comes from its public @@ -22,6 +22,16 @@ from the public specifications, and users' own `.ai` files are used locally only | Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | | **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting | +EPS editing-data follow-up (2026-10-16): both mesh-corner layouts (with and without opacity) +read in RGB, CMYK and gray. Vector opacity-mask art and its Clip, Invert, Disabled and Linked +flags are retained. Winding-declared legacy compound member groups keep their shared paint even +when followed by `9 () XW`. Fully rasterized print pages are compared using the same filtered +minification for both representations; the existing error thresholds and ordinary vector-page +checks remain. Synthetic regressions reject truncated meshes, non-unit rational weights, +unbalanced mask art, unknown mask operators, missing artwork and wrong page framing. Real +user files remain local; no SVG replacement or tracing is used. Small rendering differences +continue to produce import warnings. + ## The main format: `.ai` The format that decides beta. Illustrator's `.ai` is a PDF (or, for old files, PostScript) plus Illustrator's @@ -113,6 +123,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-16 | minor | `.ai` editing data: a rotated embedded image lands where its raster matrix puts it, not turned 180° around its corner; the legacy version 7 writer and its prolog write and draw rotated images with the same matrix (#2269). Scores unchanged | | 2026-10-14 | minor | Legacy `.ai` of version 7 runs as a PostScript program: its prolog is a procedure set of VectorCraft's own that gives each operator the writer uses its meaning (paths, colours, compound paths, clipping groups, layers that don't print, images; gradients flat, type not set), and the page trailer's `annotatepage` (§3 of the specification) marks nothing; every file the writer's tests write is run through VectorCraft's PostScript reader and must draw what the document it was written from draws (#868) | diff --git a/docs/gaps.md b/docs/gaps.md index 65d067ccf..132c865f4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make @@ -87,6 +87,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G9 Illustrator `.ai` and EPS files from real users +- **Done (2026-10-16):** EPS legacy mesh corners and vector opacity masks retained; winding-declared compound member groups keep shared paint. Raster print-page minification no longer causes a false editing-data fallback; comparison limits remain unchanged. Synthetic regressions cover damage and missing artwork. This narrows G9; scores and estimates unchanged. + - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead of 14), guides and non-printing construction lines (#779), open speed on large files (#758), live effects, brushes and symbols as live objects (#637; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope @@ -333,6 +335,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Correction to G12: quick Image Trace presets trace at once and leave the panel as it was; the Image Trace button in the Control bar or Properties opens the controls (). Wording only; no entry opened or closed, estimates unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). | diff --git a/docs/roadmap.md b/docs/roadmap.md index 6e0fe3b9a..dcaf625a9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (correction: M12's Image Trace quick presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; M2: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; legacy `.ai` of version 7 saved, #868; estimates unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: M12's Image Trace quick presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; M2: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; legacy `.ai` of version 7 saved, #868; estimates unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and [target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5 @@ -24,7 +24,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | Set type: point and area type, Character and Paragraph styles, threading, outlines | yes | type core 80%; styles, threading, Create Outlines and font matching tested ([type-parity.md](type-parity.md)); hyphenation and justification options missing (not blocking) | 0 | | Organise layers and artboards and export assets (Export for Screens, SVG, PNG, PDF per artboard) | yes | Layers panel rework, Artboards panel and tool, Export for Screens and Asset Export done; Art Optimized supersample closes ghost borders (#983 / #1037) | 0 | | Print production: CMYK and spot colour, bleed and marks, PDF/X out, separations preview | yes | PDF/X-1a/3/4, presets, marks and bleed, ICC output intent, Separations Preview, Print with PostScript output | 0 | -| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951) and lose guides (#779); Save As `.ai` is PDF-compatible, which Illustrator opens without its own editing data (#868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | +| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951) and lose guides (#779); Save As `.ai` is PDF-compatible, which Illustrator opens without its own editing data (#868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | ## Current focus @@ -53,7 +53,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (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) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; 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. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (closed SPLINE flags round-trip, #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). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip, #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). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (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; auto/slider/advanced UI remains open); 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) | 3–6 | @@ -84,6 +84,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14 | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Correction to M12: quick Image Trace presets trace at once; the Image Trace button opens the adjustment controls (). Wording only; status unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-15 | minor | Image Trace retains nonzero positioned boundaries when a fixed-end returning piece has fewer than two fitted cubics. Source-generated tests check survival, winding, shared-edge reversal and Noise controls; see `image-trace-quality.md`. Scores and hours unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9162df6c9..b35db0fb8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), @@ -83,7 +83,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows and Arrange, Snap to Glyph | 9–14 | estimated | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), guides lost (#779), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 import, 3D export; see [file-format-parity.md](file-format-parity.md) | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests_reader_streaming.rs` and `xtask/src/pdf_deps.rs` | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), guides lost (#779), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests_reader_streaming.rs` and `xtask/src/pdf_deps.rs` | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent and perpendicular snapping and its snapping panel, "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), global/artboard ruler mode switch and video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | anti-aliasing ghost borders (#983); TIFF choices and settings are implemented | 2–4 | estimated | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity | 2–4 | estimated | @@ -690,6 +690,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Type: localized legacy family names remain available when typographic names are English-only (#2208); loaded and catalog paths tested. Scores and estimates unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-16 | minor | Align Stroke on live type (beyond Illustrator): the characters' stroke goes inside or outside the glyphs without creating outlines (#2294); scores and hours unchanged | From 8b8c164e3fbe31e40d25293676f2417e3b5389f0 Mon Sep 17 00:00:00 2001 From: Roman Vasilyev Date: Fri, 16 Oct 2026 18:42:56 -0700 Subject: [PATCH 067/208] CI: simplify boolean guards and translate EPS mask errors --- crates/engine/src/cmd/object.rs | 2 +- crates/render/src/fx.rs | 2 +- crates/trace/tests/shared_boundaries.rs | 2 +- crates/ui-egui/src/i18n/cs.tsv | 2 ++ crates/ui-egui/src/i18n/de.tsv | 2 ++ crates/ui-egui/src/i18n/el.tsv | 2 ++ crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/fi.tsv | 2 ++ crates/ui-egui/src/i18n/fr.tsv | 2 ++ crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/it.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/ko.tsv | 2 ++ crates/ui-egui/src/i18n/pl.tsv | 2 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 ++ crates/ui-egui/src/i18n/ru.tsv | 2 ++ crates/ui-egui/src/i18n/sv.tsv | 2 ++ crates/ui-egui/src/i18n/uk.tsv | 2 ++ crates/ui-egui/src/i18n/vi.tsv | 2 ++ 19 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index bbd159151..bd71c073e 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -459,7 +459,7 @@ pub(crate) fn preview_copies(s: &mut Session, roots: &[NodeId], with_guides: boo if let Some(k) = &it.copies && Arc::ptr_eq(&k.from, &it.doc) && k.roots == roots - && (!k.selection.guides.is_empty() == has_guide_copies) + && (k.selection.guides.is_empty() != has_guide_copies) { return Ok(Some(k.clone())); } diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index f5281fb47..3082d49aa 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -879,7 +879,7 @@ impl Renderer { off.flush(); let mut pm = vello_cpu::Pixmap::new(w, h); off.render(&mut pm, &mut self.resources); - if !self.shadows.get(&old).is_some_and(|e| silhouette(&pm, colored) == e.made.pixels) { + if self.shadows.get(&old).is_none_or(|e| silhouette(&pm, colored) != e.made.pixels) { return; } if let Some(mut e) = self.shadows.remove(&old) { diff --git a/crates/trace/tests/shared_boundaries.rs b/crates/trace/tests/shared_boundaries.rs index 926ecd067..9210c5a80 100644 --- a/crates/trace/tests/shared_boundaries.rs +++ b/crates/trace/tests/shared_boundaries.rs @@ -270,7 +270,7 @@ fn variants(art: &Art, n: usize, soft: bool, settings: &[Setting]) -> Vec Date: Fri, 16 Oct 2026 22:41:56 +0200 Subject: [PATCH 068/208] MCP: cancel long exports, answer while one runs, report progress (#2165, #2171, #2172) The MCP server read one line, ran it and answered it before reading the next, so notifications/cancelled was never seen during a call and every request (even ping) waited behind a running export. - vectorcraft_doc::cancel: a thread-local cancel token and progress sink that long work polls. Checkpoints: PDF pages, PDF flattening, geometry bake, raster-effect flattening, drawing objects, raster artboards, and before any file is written. A cancelled command returns EngineError::Cancelled and changes nothing. - Server::serve reads on its own thread: notifications/cancelled drops a queued request or stops the running one (no reply, as the MCP spec says), engine-free requests (ping, the list methods, prompts/get) are answered at once, a request that would wait behind a call running for 2 s is refused with -32003 naming that call, and _meta.progressToken gets notifications/progress (page n of m). Queue and output are bounded. - The remote backend stops waiting for the app when the call is cancelled. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft-cli/src/main.rs | 4 +- crates/doc/src/cancel.rs | 142 ++++++++++ crates/doc/src/lib.rs | 1 + crates/effects/src/bake.rs | 3 +- crates/engine/src/cmd/fileio/encode.rs | 7 +- crates/engine/src/cmd/fileio/mod.rs | 3 + crates/engine/src/cmd/fileio/pdf.rs | 5 + crates/engine/src/cmd/flatten.rs | 5 + crates/engine/src/cmd/rasterfx.rs | 6 + crates/engine/src/lib.rs | 12 + crates/engine/src/tests_cancel.rs | 90 ++++++ crates/mcp/src/backend.rs | 37 +++ crates/mcp/src/headless.rs | 2 + crates/mcp/src/intake.rs | 309 +++++++++++++++++++++ crates/mcp/src/lib.rs | 3 + crates/mcp/src/server.rs | 281 ++++++++++++++----- crates/mcp/src/tests_concurrency.rs | 368 +++++++++++++++++++++++++ crates/mcp/tests/protocol_props.rs | 61 ++++ crates/pdf/src/export.rs | 10 +- crates/pdf/src/lib.rs | 3 + crates/render/src/lib.rs | 4 + docs/gaps.md | 4 +- docs/mcp.md | 57 ++-- docs/target-app-parity.md | 5 +- 24 files changed, 1328 insertions(+), 94 deletions(-) create mode 100644 crates/doc/src/cancel.rs create mode 100644 crates/engine/src/tests_cancel.rs create mode 100644 crates/mcp/src/intake.rs create mode 100644 crates/mcp/src/tests_concurrency.rs diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index bb3879b47..b843af67f 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -213,8 +213,8 @@ fn mcp(args: &[String]) -> Result<(), String> { // uses `vectorcraft_mcp` free to bring its own. Silent until a client sends logging/setLevel. vectorcraft_mcp::logging::install(); let stdin = std::io::stdin(); - let stdout = std::io::stdout(); - Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string()) + // The server reads and writes from threads of its own, so it takes the handles, not locks held here. + Server::new(backend).serve(std::io::BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string()) } fn commands() -> Result<(), String> { diff --git a/crates/doc/src/cancel.rs b/crates/doc/src/cancel.rs new file mode 100644 index 000000000..bff510707 --- /dev/null +++ b/crates/doc/src/cancel.rs @@ -0,0 +1,142 @@ +//! Cooperative cancellation and progress for long-running work (exports, flattening, baking). +//! +//! A host that wants to be able to stop a call (the MCP server, on `notifications/cancelled`) +//! runs it inside [`scope`] with a [`CancelToken`]. Code deep below, which has no handle on the +//! host, calls [`check`] (or [`is_cancelled`] where it can't return an error) at points where +//! stopping is safe, and returns [`Cancelled`] up through the same `Result`s it already has. The +//! token is tied to the thread that entered the scope, so concurrent sessions on other threads +//! (and tests running in parallel) never see each other's cancellation, and code outside any +//! scope is never cancelled. +//! +//! Work that stops early leaves nothing behind: callers drop whatever was half built and report +//! the error, so a cancelled command changes nothing. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// A flag one thread raises and another polls. Cloning shares the flag. +#[derive(Clone, Debug, Default)] +pub struct CancelToken(Arc); + +impl CancelToken { + pub fn new() -> Self { + Self::default() + } + + /// Ask for the work holding this token to stop. + pub fn cancel(&self) { + self.0.store(true, Ordering::Relaxed); + } + + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Relaxed) + } +} + +/// The error of work that stopped because it was cancelled. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Cancelled; + +impl std::fmt::Display for Cancelled { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("cancelled") + } +} + +impl std::error::Error for Cancelled {} + +/// Receives progress: `(done, total, what is being done)`. `total` is 0 when it isn't known. +pub type ProgressSink = Rc; + +struct Scope { + token: CancelToken, + sink: Option, +} + +thread_local! { + static CURRENT: RefCell> = const { RefCell::new(None) }; +} + +/// Puts the previous scope back, also when the work inside unwinds. +struct Restore(Option); + +impl Drop for Restore { + fn drop(&mut self) { + let previous = self.0.take(); + // `try_with`: during thread teardown the slot may be gone; nothing to restore then. + let _ = CURRENT.try_with(|c| *c.borrow_mut() = previous); + } +} + +/// Run `f` with `token` (and an optional progress `sink`) in effect on this thread. +pub fn scope(token: &CancelToken, sink: Option, f: impl FnOnce() -> T) -> T { + let previous = CURRENT.with(|c| c.borrow_mut().replace(Scope { token: token.clone(), sink })); + let _restore = Restore(previous); + f() +} + +/// Has the work running on this thread been cancelled? Always false outside a [`scope`]. +pub fn is_cancelled() -> bool { + CURRENT.with(|c| c.borrow().as_ref().is_some_and(|s| s.token.is_cancelled())) +} + +/// Is this thread running inside a [`scope`], so that a cancel can reach it? +pub fn is_cancellable() -> bool { + CURRENT.with(|c| c.borrow().is_some()) +} + +/// `Err(Cancelled)` when the work running on this thread has been cancelled. +pub fn check() -> Result<(), Cancelled> { + if is_cancelled() { Err(Cancelled) } else { Ok(()) } +} + +/// Report that `done` of `total` steps (0: unknown) are finished. A no-op outside a [`scope`] +/// that has a sink. +pub fn progress(done: u64, total: u64, what: &str) { + // The sink runs outside the borrow, so it may itself call back into this module. + let sink = CURRENT.with(|c| c.borrow().as_ref().and_then(|s| s.sink.clone())); + if let Some(sink) = sink { + sink(done, total, what); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_work_inside_the_scope_is_cancelled() { + let t = CancelToken::new(); + assert_eq!(check(), Ok(())); + scope(&t, None, || { + assert_eq!(check(), Ok(())); + t.cancel(); + assert_eq!(check(), Err(Cancelled)); + // A nested scope with its own token shadows, then restores. + scope(&CancelToken::new(), None, || assert_eq!(check(), Ok(()))); + assert!(is_cancelled()); + }); + assert!(!is_cancelled(), "the scope ended"); + } + + #[test] + fn progress_reaches_the_sink_and_the_scope_survives_a_panic() { + let seen = Rc::new(RefCell::new(vec![])); + let sink: ProgressSink = { + let seen = seen.clone(); + Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string()))) + }; + let t = CancelToken::new(); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + scope(&t, Some(sink), || { + progress(1, 3, "page"); + panic!("boom"); + }) + })); + assert!(r.is_err()); + progress(2, 3, "after"); + assert_eq!(*seen.borrow(), vec![(1, 3, "page".to_string())]); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a11f04725..3b80bf692 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -8,6 +8,7 @@ pub mod appearance; pub mod assets; pub mod blend; +pub mod cancel; pub mod clipnest; pub mod cmyk; pub mod corners; diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 0f5b89d1c..8c1b18d5e 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -89,7 +89,8 @@ fn path_kind(d: &mut Document, path: PathData, rule: FillRule) -> NodeKind { /// `n` with its geometry effects evaluated (`None` = unchanged). fn bake_node(d: &mut Document, n: &Node) -> Option { - if !needs_bake(n) { + // A cancelled export leaves the rest of the document as it is: the caller drops the result. + if !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() { return None; } // Colour adjustments: the object recoloured (inside it too), then baked as it is. diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index f37521efa..d28254f88 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -413,6 +413,8 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result { let f = super::writable(C, Some(format), None)?; let failed = vectorcraft_render::decode_failures(); let mut enc = encode_files(doc, f, p)?; + // Baking, flattening and drawing stop early when cancelled and leave a truncated result. + vectorcraft_doc::cancel::check()?; // An image the renderer couldn't decode draws nothing: say so rather than write a picture // that silently lacks it. if f.raster && vectorcraft_render::decode_failures() > failed { @@ -502,7 +504,10 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { }; let mut enc = Encoded::default(); let mut renderer = vectorcraft_render::Renderer::new(); - for b in chosen { + let total = chosen.len() as u64; + for (done, b) in chosen.into_iter().enumerate() { + vectorcraft_doc::cancel::check()?; + vectorcraft_doc::cancel::progress(done as u64, total, &format!("Rendering artboard {} of {total}", done + 1)); let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect; check_format_size(f, region.width() * scale, region.height() * scale)?; vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index b20e0db25..abdadb361 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -861,6 +861,8 @@ pub(crate) fn read_file_head(path: &str, max: usize) -> Result> { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> { + // Work that stops early for a cancel can leave a truncated result: none of it is written. + vectorcraft_doc::cancel::check()?; may_write(path)?; // Never a half-written file: see [`write_atomic`]. write_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) @@ -869,6 +871,7 @@ pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> { /// [`write_file`] with what `fill` writes into the file (a save streamed into it). #[cfg(not(target_arch = "wasm32"))] pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> { + vectorcraft_doc::cancel::check()?; may_write(path)?; vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 1703bece0..4af802a0a 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -169,6 +169,7 @@ pub(crate) fn merge(base: &mut Value, over: &Value) { pub(crate) fn pdf_error(cmd: &str, e: PdfError) -> EngineError { match e { PdfError::BadSetting(_) | PdfError::Unsupported(_) => bad(cmd, e.to_string()), + PdfError::Cancelled => EngineError::Cancelled, e => EngineError::Other(e.to_string()), } } @@ -273,7 +274,11 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl } } let set = &opts.settings; + if set.pdf13() { + vectorcraft_doc::cancel::progress(0, 0, "Flattening transparency"); + } let flat = flatten_for(cmd, doc, set, p)?; + vectorcraft_doc::cancel::check()?; if flat.is_some() { let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() }; warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset")); diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 104af70d2..842293892 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -818,7 +818,10 @@ fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> R let see_through: Vec = plain.iter().map(|n| n.shows_transparency() || o.draws_see_through_image(n)).collect(); let cmyk = src.color_mode == ColorMode::Cmyk; let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() }; + // Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing). + vectorcraft_doc::cancel::check()?; for g in overlapping(&reaches) { + vectorcraft_doc::cancel::check()?; let ids: Vec = g.iter().map(|&i| roots[i]).collect(); let group = g.iter().any(|&i| see_through[i]).then(|| { let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect(); @@ -950,6 +953,8 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio ); let mut r = vectorcraft_render::Renderer::new(); let mut img = r.render_region(&isolated_doc(doc, art.to_vec()), region, scale, !o.preserve_alpha); + // The drawing stops early when cancelled: this image is not whole. + vectorcraft_doc::cancel::check()?; if o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha { // Without transparency the clip alone shapes the image: it is opaque, over white (its // edge pixels too). diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 3bab565d0..554afb881 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -440,6 +440,10 @@ fn walk_mask(out: &mut Document, n: &mut Node, report: &mut FlattenReport) { /// has none. An object whose effects all paint below it keeps its vector art over an image of /// them; one whose effects change it (blur, feather, inner glow) becomes the image. fn walk(out: &mut Document, n: &Node, report: &mut FlattenReport) -> Option { + // A cancelled export leaves the rest of the document as it is: the caller drops the result. + if vectorcraft_doc::cancel::is_cancelled() { + return None; + } let checkpoint = report.checkpoint(); let result = walk_inner(out, n, report); report.attribute_since(checkpoint, n); @@ -558,6 +562,8 @@ pub fn export_pdf_with_report( ) -> std::result::Result { let mut report = FlattenReport::new(Which::All, doc); let flat = flatten(doc, &mut report); + // Flattening stops early when cancelled, leaving a half-flattened copy that must not be written. + vectorcraft_doc::cancel::check().map_err(|_| vectorcraft_pdf::PdfError::Cancelled)?; let mut exported = vectorcraft_pdf::export_with_report(flat.as_ref().unwrap_or(doc), opts)?; exported.warnings.extend(report.warnings()); Ok(exported) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 395a83910..30ffda9f4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -34,6 +34,7 @@ pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use journal::Journal; pub use tooling::{UiRequest, ViewInfo}; pub use vectorcraft_doc as doc; +pub use vectorcraft_doc::cancel; pub use vectorcraft_tools as tools; #[derive(Debug, thiserror::Error)] @@ -50,11 +51,20 @@ pub enum EngineError { NoNode(NodeId), #[error("{0}")] Other(String), + /// The caller asked for the command to stop ([`cancel`]). It changed nothing. + #[error("cancelled; nothing was changed")] + Cancelled, /// A bug: the command panicked. The document was rolled back to its state before the command. #[error("internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug)")] Internal { cmd: String, msg: String }, } +impl From for EngineError { + fn from(_: cancel::Cancelled) -> Self { + EngineError::Cancelled + } +} + impl From for EngineError { fn from(e: vectorcraft_doc::DocError) -> Self { EngineError::Other(e.to_string()) @@ -1892,6 +1902,8 @@ mod tests_brushsym; #[cfg(test)] mod tests_build; #[cfg(test)] +mod tests_cancel; +#[cfg(test)] mod tests_charstroke; #[cfg(test)] mod tests_clip; diff --git a/crates/engine/src/tests_cancel.rs b/crates/engine/src/tests_cancel.rs new file mode 100644 index 000000000..8237707a1 --- /dev/null +++ b/crates/engine/src/tests_cancel.rs @@ -0,0 +1,90 @@ +//! Cancelling a long export (#2165): it stops at the next checkpoint with `EngineError::Cancelled`, +//! writes nothing, leaves the document as it was, and the session goes on working. + +use std::cell::Cell; +use std::rc::Rc; + +use serde_json::json; + +use crate::cancel::{self, CancelToken, ProgressSink}; +use crate::{EngineError, Session}; + +fn session(artboards: usize) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": artboards})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); + s +} + +fn revision(s: &Session) -> u64 { + s.doc().unwrap().revision +} + +#[test] +fn a_pdf_export_cancelled_between_pages_stops_writes_nothing_and_changes_nothing() { + let path = vectorcraft_testkit::temp_dir("cancel-pdf").join("out.pdf"); + std::fs::remove_file(&path).ok(); + let mut s = session(5); + let before = (revision(&s), s.doc().unwrap().history.undo.len()); + let token = CancelToken::new(); + let pages = Rc::new(Cell::new(0)); + // The sink runs inside the export, as the page loop reports each page: cancel on the third. + let sink: ProgressSink = { + let (token, pages) = (token.clone(), pages.clone()); + Rc::new(move |done, total, _| { + // Stages before the pages report without a total. + if total != 5 { + return; + } + pages.set(done); + if done == 3 { + token.cancel(); + } + }) + }; + let params = json!({"path": path.to_str().unwrap()}); + let r = cancel::scope(&token, Some(sink), || s.execute("document.exportPdf", ¶ms)); + assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}"); + assert_eq!(pages.get(), 3, "the export stopped at the checkpoint after the cancel, not at the end"); + assert!(!path.exists(), "a cancelled export writes no file"); + assert_eq!((revision(&s), s.doc().unwrap().history.undo.len()), before, "the document is as it was"); + // The session is fine: the same export, not cancelled, completes. + s.execute("document.exportPdf", ¶ms).unwrap(); + assert!(path.exists()); +} + +#[test] +fn a_cancelled_flattening_export_stops_before_the_pages() { + let mut s = session(2); + let token = CancelToken::new(); + token.cancel(); + // PDF/X-1a flattens transparency first; a token raised beforehand stops at the first check. + let r = cancel::scope(&token, None, || s.execute("document.exportPdf", &json!({"standard": "pdfX1a"}))); + assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}"); + assert!(s.execute("document.exportPdf", &json!({"standard": "pdfX1a"})).is_ok()); +} + +#[test] +fn raster_exports_and_svg_stop_when_cancelled_too() { + let dir = vectorcraft_testkit::temp_dir("cancel-formats"); + let mut s = session(3); + let token = CancelToken::new(); + token.cancel(); + for ext in ["png", "svg", "pdf"] { + let path = dir.join(format!("out.{ext}")); + std::fs::remove_file(&path).ok(); + let params = json!({"path": path.to_str().unwrap()}); + let r = cancel::scope(&token, None, || s.execute("document.export", ¶ms)); + assert!(matches!(r, Err(EngineError::Cancelled)), "{ext}: {r:?}"); + assert!(!path.exists(), "{ext}: nothing is written"); + } +} + +#[test] +fn nothing_is_cancelled_outside_a_scope() { + let token = CancelToken::new(); + token.cancel(); + // The token only counts for work run under it, on its thread. + let mut s = session(1); + assert!(s.execute("document.exportPdf", &json!({})).is_ok()); +} diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index b3a00604a..97c990b88 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -85,6 +85,42 @@ impl Remote { Ok(()) } + /// Wait until the app starts to answer, giving up when the call is cancelled + /// ([`vectorcraft_engine::cancel`]): the app has no way to stop what it runs, but this server + /// stops waiting for it (the connection is dropped, so a late answer can't be mistaken for + /// the next call's). Without a cancellable call this is just the read the caller does next. + fn wait_for_reply(stream: &TcpStream) -> std::io::Result<()> { + const POLL: Duration = Duration::from_millis(100); + const LIMIT: Duration = Duration::from_secs(90); + if !vectorcraft_engine::cancel::is_cancellable() { + return Ok(()); + } + let start = std::time::Instant::now(); + let kept = stream.read_timeout()?; + stream.set_read_timeout(Some(POLL))?; + let outcome = loop { + match stream.peek(&mut [0]) { + // Bytes (or the end of the stream) to read now. + Ok(_) => break Ok(()), + Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => { + if vectorcraft_engine::cancel::is_cancelled() { + break Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "cancelled; the app may still finish what it was asked, which can't be stopped from here", + )); + } + if start.elapsed() >= LIMIT { + break Err(e); + } + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => break Err(e), + } + }; + stream.set_read_timeout(kept)?; + outcome + } + fn roundtrip(&mut self, line: &str) -> std::io::Result { let Some((reader, writer)) = self.conn.as_mut() else { return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected to the app")); @@ -92,6 +128,7 @@ impl Remote { writer.write_all(line.as_bytes())?; writer.write_all(b"\n")?; writer.flush()?; + Self::wait_for_reply(writer)?; let mut buf = Vec::new(); if !crate::server::read_line_capped(reader, &mut buf, MAX_REPLY_BYTES)? { return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed")); diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index d11e5eeb0..9442339e5 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -253,6 +253,8 @@ impl Headless { let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0); vectorcraft_render::raster_size(r, scale)?; let img = self.renderer.render_region(&doc, r, scale, true); + // A cancelled render stops drawing: the picture is not whole and is not written. + vectorcraft_engine::cancel::check().map_err(|e| e.to_string())?; let png = img.to_png()?; let mut out = json!({"width": img.width, "height": img.height}); match s(p, "path") { diff --git a/crates/mcp/src/intake.rs b/crates/mcp/src/intake.rs new file mode 100644 index 000000000..186bdbfd8 --- /dev/null +++ b/crates/mcp/src/intake.rs @@ -0,0 +1,309 @@ +//! What lets the server hear a client while a call runs: a reader thread that takes lines off the +//! transport, a queue the engine thread works through, and a writer thread that owns the output. +//! +//! The engine runs one command at a time, on the thread that called [`crate::Server::serve`]. The +//! reader never touches the engine. While a call runs it can still +//! +//! - act on [`notifications/cancelled`](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation): +//! a queued request is dropped, a running one has its [`CancelToken`] raised, and neither gets +//! a reply; +//! - answer the requests that need no engine (`ping`, the `…/list` methods, `prompts/get`), and +//! - refuse, with a "busy" error that names the running call, a request that would have to wait +//! behind a call that has already run for [`Server::with_busy_after`](crate::Server::with_busy_after). +//! +//! Requests that arrive sooner than that queue up and run in order, so a client that pipelines a +//! few quick calls sees them answered in order, as before. The queue is bounded: past +//! [`MAX_QUEUED`] lines the reader waits for the engine rather than buffering without bound. + +use std::collections::VecDeque; +use std::io::BufRead; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::SyncSender; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use vectorcraft_engine::cancel::CancelToken; + +use crate::server::{INVALID_REQUEST, MAX_LINE_BYTES, PARSE_ERROR, SERVER_BUSY, error, error_with, read_line_capped}; + +/// Most lines waiting for the engine. Past this the reader waits (it still reads nothing else). +pub(crate) const MAX_QUEUED: usize = 64; + +/// A line the engine thread has still to run. +pub(crate) enum Job { + /// A reply that is already known (a parse error, an overlong line), kept in order with the rest. + Canned(String), + /// A parsed message: a request, a notification or a batch. + Message(Message), +} + +pub(crate) struct Message { + pub(crate) value: Value, + /// The request id as JSON text, for a request; what `notifications/cancelled` names. + pub(crate) key: Option, + pub(crate) method: String, + /// Raised by `notifications/cancelled`, or when the transport closes under the call. + pub(crate) token: CancelToken, + /// What the call is, for the "busy" answer: `tools/call command_run document.export`. + pub(crate) label: String, +} + +/// The call the engine thread is running. +struct Running { + key: Option, + method: String, + token: CancelToken, + label: String, + since: Instant, +} + +#[derive(Default)] +struct State { + queue: VecDeque, + running: Option, + /// The transport has no more lines. + eof: bool, + /// The output failed: nobody reads replies any more. + closed: bool, +} + +/// What the reader and the engine thread share. +pub(crate) struct Shared { + state: Mutex, + wake: Condvar, + /// Whether the client speaks the `2026-07-28` revision, for results answered by the reader. + pub(crate) modern: Arc, + busy_after: Duration, +} + +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + // A thread that panicked while holding the lock left a queue that is still well formed. + m.lock().unwrap_or_else(PoisonError::into_inner) +} + +impl Shared { + pub(crate) fn new(modern: Arc, busy_after: Duration) -> Self { + Self { state: Mutex::new(State::default()), wake: Condvar::new(), modern, busy_after } + } + + /// The next job, or `None` once the transport is done and the queue empty (or the output + /// failed). The job counts as running until [`Shared::finished`]. + pub(crate) fn next(&self) -> Option { + let mut st = lock(&self.state); + loop { + if st.closed { + return None; + } + if let Some(job) = st.queue.pop_front() { + if let Job::Message(m) = &job { + st.running = Some(Running { + key: m.key.clone(), + method: m.method.clone(), + token: m.token.clone(), + label: m.label.clone(), + since: Instant::now(), + }); + } + // The reader may be waiting for room. + self.wake.notify_all(); + return Some(job); + } + if st.eof { + return None; + } + st = self.wake.wait(st).unwrap_or_else(PoisonError::into_inner); + } + } + + pub(crate) fn finished(&self) { + lock(&self.state).running = None; + self.wake.notify_all(); + } + + /// The output failed: stop the running call and everything queued behind it. + pub(crate) fn close(&self) { + let mut st = lock(&self.state); + st.closed = true; + if let Some(r) = &st.running { + r.token.cancel(); + } + st.queue.clear(); + self.wake.notify_all(); + } + + fn end_of_input(&self) { + lock(&self.state).eof = true; + self.wake.notify_all(); + } + + fn is_closed(&self) -> bool { + lock(&self.state).closed + } + + /// The running call, once it has run for `busy_after` (so a quick call never makes a + /// pipelined neighbour fail). + fn long_running(&self, st: &State) -> Option { + let r = st.running.as_ref()?; + let secs = r.since.elapsed(); + (secs >= self.busy_after).then(|| json!({"running": r.label, "requestId": r.key, "seconds": secs.as_secs()})) + } +} + +/// The id of a request message: `Some` when `msg` is a JSON-RPC 2.0 request with a usable id. +fn request_id(msg: &Value) -> Option<&Value> { + let o = msg.as_object()?; + if o.get("jsonrpc").and_then(Value::as_str) != Some("2.0") || !o.get("method")?.is_string() { + return None; + } + o.get("id").filter(|id| id.is_string() || id.is_number()) +} + +/// A short name for the call, as the "busy" answer shows it. +fn label_of(method: &str, params: &Value) -> String { + if method != "tools/call" { + return method.to_string(); + } + let name = params.get("name").and_then(Value::as_str).unwrap_or("?"); + let args = params.get("arguments"); + let which = ["id", "command", "path"].iter().find_map(|k| args?.get(k)?.as_str()); + match which { + Some(w) => format!("{method} {name} {w}"), + None => format!("{method} {name}"), + } +} + +/// Read lines from `reader` until the end, acting on each as described in the module docs. +pub(crate) fn read_loop(shared: &Shared, out: &SyncSender, mut reader: impl BufRead) -> std::io::Result<()> { + let mut buf = Vec::new(); + let result = loop { + match read_line_capped(&mut reader, &mut buf, MAX_LINE_BYTES) { + Ok(true) => {} + Ok(false) => break Ok(()), + Err(e) => break Err(e), + } + if shared.is_closed() { + break Ok(()); + } + if buf.len() > MAX_LINE_BYTES { + // Longer than anything a client can mean: answer the framing violation, skip the + // rest of the line in bounded chunks, and keep serving what follows. + let reply = error(Value::Null, INVALID_REQUEST, format!("request line longer than {MAX_LINE_BYTES} bytes")).to_string(); + submit_canned(shared, out, reply); + let ended = loop { + match read_line_capped(&mut reader, &mut buf, MAX_LINE_BYTES) { + Ok(true) if buf.len() > MAX_LINE_BYTES => {} + Ok(true) => break Ok(false), + Ok(false) => break Ok(true), + Err(e) => break Err(e), + } + }; + match ended { + Ok(false) => continue, + Ok(true) => break Ok(()), + Err(e) => break Err(e), + } + } + let line = match std::str::from_utf8(&buf) { + Ok(l) => l, + Err(e) => break Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)), + }; + intake(shared, out, line); + }; + shared.end_of_input(); + result +} + +/// A reply that is already known: in order behind queued work, or at once when the engine is +/// tied up for long (then nobody can expect order). +fn submit_canned(shared: &Shared, out: &SyncSender, reply: String) { + let mut st = lock(&shared.state); + if shared.long_running(&st).is_some() { + drop(st); + let _ = out.send(reply); + return; + } + st.queue.push_back(Job::Canned(reply)); + shared.wake.notify_all(); +} + +fn intake(shared: &Shared, out: &SyncSender, line: &str) { + let line = line.trim(); + if line.is_empty() { + return; + } + let value = match serde_json::from_str::(line) { + Ok(v) => v, + Err(e) => return submit_canned(shared, out, error(Value::Null, PARSE_ERROR, format!("parse error: {e}")).to_string()), + }; + let method = value.get("method").and_then(Value::as_str).unwrap_or("").to_string(); + let enveloped = value.get("jsonrpc").and_then(Value::as_str) == Some("2.0"); + if enveloped && method == "notifications/cancelled" && value.get("id").is_none() { + return cancel(shared, value.get("params").unwrap_or(&Value::Null)); + } + let params = value.get("params").cloned().unwrap_or(Value::Null); + let id = request_id(&value).cloned(); + let is_batch = value.is_array(); + let token = CancelToken::new(); + let msg = Message { key: id.as_ref().map(Value::to_string), label: label_of(&method, ¶ms), method, token, value }; + let mut st = lock(&shared.state); + loop { + if st.closed { + return; + } + let busy = shared.long_running(&st); + if let Some(busy) = busy { + if let Some(id) = &id { + // Engine-free requests are answered here; others are told to retry. + let reply = match crate::server::stateless_reply(&msg.value, shared.modern.load(Ordering::Relaxed)) { + Some(r) => r, + None => error_with(id.clone(), SERVER_BUSY, busy_message(&busy), json!({"busy": busy})), + }; + drop(st); + let _ = out.send(reply.to_string()); + return; + } + if is_batch { + drop(st); + let reply = error_with(Value::Null, SERVER_BUSY, busy_message(&busy), json!({"busy": busy})); + let _ = out.send(reply.to_string()); + return; + } + } + if st.queue.len() < MAX_QUEUED { + break; + } + // Wake now and then: the call that fills the queue may be the one that turns it away. + st = shared.wake.wait_timeout(st, Duration::from_millis(50)).unwrap_or_else(PoisonError::into_inner).0; + } + st.queue.push_back(Job::Message(msg)); + shared.wake.notify_all(); +} + +fn busy_message(busy: &Value) -> String { + format!( + "the engine is busy: `{}` has been running for {} s and this request was not run. Retry when it has finished, or cancel it with notifications/cancelled (requestId {})", + busy["running"].as_str().unwrap_or("a call"), + busy["seconds"].as_u64().unwrap_or(0), + busy["requestId"], + ) +} + +/// `notifications/cancelled {requestId}`: stop that request if it is still queued or running. +/// An unknown or finished id is ignored, as is a request that can't be cancelled (`initialize`). +fn cancel(shared: &Shared, params: &Value) { + let Some(id) = params.get("requestId").filter(|id| id.is_string() || id.is_number()) else { return }; + let key = id.to_string(); + let mut st = lock(&shared.state); + if let Some(r) = st.running.as_ref().filter(|r| r.key.as_deref() == Some(&key) && r.method != "initialize") { + log::debug!("cancelling running request {key}: {}", r.label); + r.token.cancel(); + return; + } + let at = st.queue.iter().position(|j| matches!(j, Job::Message(m) if m.key.as_deref() == Some(&key) && m.method != "initialize")); + if let Some(at) = at { + log::debug!("dropping queued request {key}"); + st.queue.remove(at); + shared.wake.notify_all(); + } +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 73a37272d..1c7b7020c 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -16,6 +16,7 @@ mod backend; mod headless; +mod intake; pub mod logging; mod prompts; mod resources; @@ -37,6 +38,8 @@ mod tests; #[cfg(test)] mod tests_batchdraw; #[cfg(test)] +mod tests_concurrency; +#[cfg(test)] mod tests_conventions; #[cfg(test)] mod tests_distortkeys; diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index ba5b23c13..8fef0ee46 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -1,10 +1,18 @@ //! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources / prompts methods. +use std::cell::Cell; use std::io::{BufRead, Write}; +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{SyncSender, sync_channel}; +use std::time::{Duration, Instant}; use serde_json::{Value, json}; +use vectorcraft_engine::cancel; use crate::backend::Backend; +use crate::intake::{Job, Message, Shared, read_loop}; use crate::prompts; use crate::resources; use crate::tools::{call_tool, tool_definitions}; @@ -14,12 +22,18 @@ pub const PROTOCOL_VERSION: &str = "2025-06-18"; const MODERN_VERSION: &str = "2026-07-28"; const SUPPORTED_VERSIONS: &[&str] = &[MODERN_VERSION, PROTOCOL_VERSION, "2025-03-26", "2024-11-05"]; -const PARSE_ERROR: i64 = -32700; -const INVALID_REQUEST: i64 = -32600; +pub(crate) const PARSE_ERROR: i64 = -32700; +pub(crate) const INVALID_REQUEST: i64 = -32600; const METHOD_NOT_FOUND: i64 = -32601; const INVALID_PARAMS: i64 = -32602; const INTERNAL_ERROR: i64 = -32603; const RESOURCE_NOT_FOUND: i64 = -32002; +/// The engine is running a long call and this request was not run (implementation-defined range). +pub(crate) const SERVER_BUSY: i64 = -32003; + +/// How long a call runs before a request that would have to wait behind it is refused as busy +/// instead ([`Server::with_busy_after`]). +const DEFAULT_BUSY_AFTER: Duration = Duration::from_secs(2); /// Longest accepted request line (bytes, without the newline). Requests are small JSON objects; /// anything longer is answered with an error and skipped, so one huge line can't make the server @@ -29,6 +43,40 @@ pub(crate) const MAX_LINE_BYTES: usize = 16 * 1024 * 1024; /// older clients can make the server parse and answer at once (#1727). pub(crate) const MAX_BATCH_MESSAGES: usize = 64; +/// Lines waiting to be written. A full queue holds up the thread that wants to write (the +/// engine thread, the reader) rather than growing; progress is dropped instead (it is advisory). +const OUTBOX: usize = 256; +/// Fewest time between two `notifications/progress` of one request, the last one excepted. +const PROGRESS_EVERY: Duration = Duration::from_millis(200); + +/// The sink that turns the engine's progress into `notifications/progress` for the request +/// `msg`, when the client asked for it with `_meta.progressToken`. +/// +fn progress_sink(out: SyncSender, msg: &Value) -> Option { + let token = msg.get("params")?.get("_meta")?.get("progressToken").filter(|t| t.is_string() || t.is_number())?.clone(); + let last = Cell::new(None::); + let at = Cell::new(None::); + Some(Rc::new(move |done, total, what| { + // `progress` must increase with each notification. + if last.get().is_some_and(|l| done <= l) { + return; + } + let finished = total > 0 && done >= total; + if !finished && at.get().is_some_and(|t| t.elapsed() < PROGRESS_EVERY) { + return; + } + last.set(Some(done)); + at.set(Some(Instant::now())); + let mut params = json!({"progressToken": token, "progress": done, "message": what}); + if total > 0 { + params["total"] = json!(total); + } + let line = json!({"jsonrpc": "2.0", "method": "notifications/progress", "params": params}).to_string(); + // Advisory: a client that doesn't read fast enough misses an update, not the reply. + let _ = out.try_send(line); + })) +} + const INSTRUCTIONS: &str = "VectorCraft is a professional vector illustration app. Coordinates are \ points in document space (y down, origin at the first artboard's top-left; a new document is 612×792). \ Draw with draw_shape / draw_path, change colours with set_paint, look with render_preview and doc_inspect. \ @@ -42,20 +90,88 @@ vectorcraft://swatch/{name} read one thing at a time instead of the whole docume pub struct Server { backend: Box, initialized: bool, - modern: bool, + /// Whether the client speaks the `2026-07-28` revision. Shared with the reader thread, which + /// answers some requests itself while the engine is busy. + modern: Arc, /// Whether this server's client asked for log records (`logging/setLevel`). The queue is /// process-wide, so a server whose client didn't ask leaves it to the one that did. logging: bool, + busy_after: Duration, } -fn response(id: Value, result: Value) -> Value { +pub(crate) fn response(id: Value, result: Value) -> Value { json!({"jsonrpc": "2.0", "id": id, "result": result}) } -fn error(id: Value, code: i64, message: impl Into) -> Value { +pub(crate) fn error(id: Value, code: i64, message: impl Into) -> Value { json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}}) } +pub(crate) fn error_with(id: Value, code: i64, message: impl Into, data: Value) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into(), "data": data}}) +} + +/// The requests that need neither the engine nor the server's state beyond the protocol revision, +/// so the reader can answer them while the engine is busy. +pub(crate) fn stateless_request(method: &str, params: &Value) -> Option> { + Some(match method { + "ping" => Ok(json!({})), + "tools/list" => Ok(json!({"tools": tool_definitions()})), + "resources/list" => Ok(resources::list()), + "resources/templates/list" => Ok(resources::templates()), + "prompts/list" => Ok(prompts::list()), + "prompts/get" => { + let name = params.get("name").and_then(Value::as_str); + let args = params.get("arguments").cloned().unwrap_or(Value::Null); + match name { + Some(name) => prompts::get(name, &args).map_err(|e| (INVALID_PARAMS, e)), + None => Err((INVALID_PARAMS, "missing prompt `name`".to_string())), + } + } + _ => return None, + }) +} + +/// The reply to `msg` when it is a request [`stateless_request`] answers. +pub(crate) fn stateless_reply(msg: &Value, modern: bool) -> Option { + let o = msg.as_object()?; + let method = o.get("method")?.as_str()?; + let id = o.get("id").filter(|id| id.is_string() || id.is_number())?.clone(); + if o.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return None; + } + let params = o.get("params").cloned().unwrap_or(Value::Null); + // A panic here is a bug: leave the request to the engine thread, which reports it. + let r = vectorcraft_engine::guard::catch_panic(|| stateless_request(method, ¶ms)).ok()??; + Some(match r { + Ok(r) => response(id, complete_result(method, ¶ms, modern, r)), + Err((code, m)) => error(id, code, m), + }) +} + +/// The cache hints the `2026-07-28` revision adds to list and read results. `modern` is whether the +/// session negotiated that revision; a request can also name it in its `_meta`. +pub(crate) fn complete_result(method: &str, params: &Value, modern: bool, mut r: Value) -> Value { + let modern = params + .get("_meta") + .and_then(|m| m.get("io.modelcontextprotocol/protocolVersion")) + .and_then(Value::as_str) + .map_or(modern, |v| v == MODERN_VERSION); + if modern && let Some(result) = r.as_object_mut() { + let ttl = match method { + "tools/list" | "resources/list" | "resources/templates/list" | "prompts/list" => Some(600_000), + "resources/read" | "prompts/get" => Some(0), + _ => None, + }; + if let Some(ttl) = ttl { + result.insert("resultType".into(), json!("complete")); + result.insert("ttlMs".into(), json!(ttl)); + result.insert("cacheScope".into(), json!("private")); + } + } + r +} + /// Read one `\n`-terminated line into `buf` (newline stripped). `Ok(false)` at end of stream. /// `buf` holding more than `max` bytes means the line is overlong: no newline arrived within the /// bound, so the bytes so far are only its start. Only `max + 1` bytes are ever buffered for one @@ -101,7 +217,15 @@ fn write_reply(output: &mut impl Write, line: &str) -> std::io::Result<()> { impl Server { pub fn new(backend: Box) -> Self { - Self { backend, initialized: false, modern: false, logging: false } + Self { backend, initialized: false, modern: Arc::new(AtomicBool::new(false)), logging: false, busy_after: DEFAULT_BUSY_AFTER } + } + + /// How long a call runs before [`Server::serve`] turns away a request that would have to wait + /// behind it, with an error naming the call (default two seconds). Shorter waits queue, so a + /// client pipelining quick calls is answered in order. + pub fn with_busy_after(mut self, after: Duration) -> Self { + self.busy_after = after; + self } pub fn backend(&mut self) -> &mut dyn Backend { @@ -130,36 +254,76 @@ impl Server { /// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is /// the protocol stream). - pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { - let mut reader = input; - let mut buf = Vec::new(); - loop { - if !read_line_capped(&mut reader, &mut buf, MAX_LINE_BYTES)? { - return Ok(()); - } - if buf.len() > MAX_LINE_BYTES { - // Longer than anything a client can mean: answer the framing violation, skip the - // rest of the line in bounded chunks, and keep serving what follows. - write_reply( - &mut output, - &error(Value::Null, INVALID_REQUEST, format!("request line longer than {MAX_LINE_BYTES} bytes")).to_string(), - )?; - loop { - if !read_line_capped(&mut reader, &mut buf, MAX_LINE_BYTES)? { - return Ok(()); - } - if buf.len() <= MAX_LINE_BYTES { - break; + /// + /// A reader thread takes lines off `input` while the calling thread runs them, so the server + /// hears a client during a long call: `notifications/cancelled` stops a queued or running + /// request (which then gets no reply), and `ping` and the other engine-free requests are + /// answered at once. A request that would have to wait behind a call that has run for + /// [`Server::with_busy_after`] is refused with a "busy" error. Calls still run one at a time, + /// in arrival order, and replies of quick calls keep that order. See the module docs of + /// `intake` and `docs/mcp.md`. + /// + /// Returns when `input` ends and everything read has been answered, or when `output` fails + /// (the running call is then cancelled and the queue dropped; the reader thread ends with its + /// next line, which a client that closed our output closes as well). + pub fn serve(&mut self, input: R, mut output: W) -> std::io::Result<()> { + let shared = Shared::new(self.modern.clone(), self.busy_after); + let shared = &shared; + let (tx, rx) = sync_channel::(OUTBOX); + let reader_tx = tx.clone(); + std::thread::scope(|scope| { + // The only thread that writes: replies, progress and log records stay whole lines. + let writer = scope.spawn(move || { + let mut failed = None; + for line in rx { + if failed.is_none() + && let Err(e) = write_reply(&mut output, &line) + { + failed = Some(e); + shared.close(); } } - continue; + failed + }); + let reader = scope.spawn(move || read_loop(shared, &reader_tx, input)); + self.work(shared, &tx); + drop(tx); + let read = reader.join().unwrap_or_else(|_| Err(std::io::Error::other("the MCP reader thread panicked"))); + let written = writer.join().unwrap_or_else(|_| Some(std::io::Error::other("the MCP writer thread panicked"))); + match written { + Some(e) => Err(e), + None => read, } - let line = std::str::from_utf8(&buf).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - let reply = self.handle_line(line); - let notes = self.take_notifications(); - for line in reply.iter().chain(notes.iter()) { - write_reply(&mut output, line)?; + }) + } + + /// Run the queued jobs, one at a time, until the reader is done and the queue empty. + fn work(&mut self, shared: &Shared, out: &SyncSender) { + while let Some(job) = shared.next() { + match job { + Job::Canned(line) => { + let _ = out.send(line); + } + Job::Message(m) => self.run(m, out), } + for note in self.take_notifications() { + let _ = out.send(note); + } + shared.finished(); + } + } + + fn run(&mut self, m: Message, out: &SyncSender) { + let Message { value, key, token, .. } = m; + let sink = key.is_some().then(|| progress_sink(out.clone(), &value)).flatten(); + let reply = cancel::scope(&token, sink, || self.handle_parsed(value)); + // A cancelled request gets no reply (MCP: the receiver should not answer it). A call that + // finished before it noticed is dropped the same way: the client has stopped waiting. + if key.is_some() && token.is_cancelled() { + return; + } + if let Some(reply) = reply { + let _ = out.send(reply.to_string()); } } @@ -170,7 +334,16 @@ impl Server { return None; } let reply = match serde_json::from_str::(line) { - Ok(Value::Array(batch)) => { + Ok(value) => self.handle_parsed(value), + Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))), + }; + reply.map(|r| r.to_string()) + } + + /// Handle one parsed line: a message, or a batch of them. + fn handle_parsed(&mut self, value: Value) -> Option { + match value { + Value::Array(batch) => { // Batches were removed in 2025-06-18; still answer older clients sensibly. if batch.is_empty() { Some(error(Value::Null, INVALID_REQUEST, "empty batch")) @@ -181,10 +354,8 @@ impl Server { (!replies.is_empty()).then_some(Value::Array(replies)) } } - Ok(msg) => self.handle(msg), - Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))), - }; - reply.map(|r| r.to_string()) + msg => self.handle(msg), + } } /// Handle one JSON-RPC message; `None` for notifications and responses. @@ -216,26 +387,7 @@ impl Server { let r = vectorcraft_engine::guard::catch_panic(|| self.request(method, ¶ms)) .unwrap_or_else(|msg| Err((INTERNAL_ERROR, format!("internal error in `{method}`: {msg} (please report this bug)")))); Some(match r { - Ok(mut r) => { - let modern = params - .get("_meta") - .and_then(|m| m.get("io.modelcontextprotocol/protocolVersion")) - .and_then(Value::as_str) - .map_or(self.modern, |v| v == MODERN_VERSION); - if modern && let Some(result) = r.as_object_mut() { - let ttl = match method { - "tools/list" | "resources/list" | "resources/templates/list" | "prompts/list" => Some(600_000), - "resources/read" | "prompts/get" => Some(0), - _ => None, - }; - if let Some(ttl) = ttl { - result.insert("resultType".into(), json!("complete")); - result.insert("ttlMs".into(), json!(ttl)); - result.insert("cacheScope".into(), json!("private")); - } - } - response(id, r) - } + Ok(r) => response(id, complete_result(method, ¶ms, self.modern.load(Ordering::Relaxed), r)), Err((code, m)) => error(id, code, m), }) } @@ -249,11 +401,14 @@ impl Server { } fn request(&mut self, method: &str, params: &Value) -> Result { + if let Some(r) = stateless_request(method, params) { + return r; + } match method { "initialize" => { let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION); let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION }; - self.modern = version == MODERN_VERSION; + self.modern.store(version == MODERN_VERSION, Ordering::Relaxed); Ok(json!({ "protocolVersion": version, "capabilities": { @@ -267,8 +422,6 @@ impl Server { "instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()), })) } - "ping" => Ok(json!({})), - "tools/list" => Ok(json!({"tools": tool_definitions()})), "tools/call" => { let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?; let args = params.get("arguments").cloned().unwrap_or(Value::Null); @@ -277,8 +430,6 @@ impl Server { } Ok(call_tool(self.backend.as_mut(), name, &args).to_value()) } - "resources/list" => Ok(resources::list()), - "resources/templates/list" => Ok(resources::templates()), "resources/read" => { let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?; let v = resources::read(self.backend.as_mut(), uri).map_err(|e| match e { @@ -288,12 +439,6 @@ impl Server { })?; Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]})) } - "prompts/list" => Ok(prompts::list()), - "prompts/get" => { - let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing prompt `name`".to_string()))?; - let args = params.get("arguments").cloned().unwrap_or(Value::Null); - prompts::get(name, &args).map_err(|e| (INVALID_PARAMS, e)) - } "completion/complete" => Ok(prompts::complete(self.backend.as_mut(), params)), "logging/setLevel" => { // `level` is required. A null level is our own way of turning logging back off, diff --git a/crates/mcp/src/tests_concurrency.rs b/crates/mcp/src/tests_concurrency.rs new file mode 100644 index 000000000..c2e23a77a --- /dev/null +++ b/crates/mcp/src/tests_concurrency.rs @@ -0,0 +1,368 @@ +//! Reading while a call runs: `ping`, cancellation and busy answers reach the client while a long +//! command holds the engine (#2165, #2171). Driven through [`Server::serve`] on a pipe, the way a +//! client on stdio drives it, with a backend that works until it is told to stop. + +use std::io::{BufReader, Write}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use vectorcraft_engine::cancel; + +use crate::{Backend, Server}; + +/// A backend whose `engine.execute` runs until cancelled (or for `limit`), like a long export. +struct Slow { + limit: Duration, + /// Commands that ran to the end, and the ones that were cancelled. + log: Arc>>, + /// Told when a command starts. + started: Sender, +} + +impl Backend for Slow { + fn call(&mut self, method: &str, params: Value) -> Result { + if method != "engine.execute" { + return Ok(json!({"method": method})); + } + let id = params.get("command").and_then(Value::as_str).unwrap_or("?").to_string(); + self.started.send(id.clone()).ok(); + if id == "progress.demo" { + for page in 1..=3 { + // Enough apart to pass the sender's throttle. + std::thread::sleep(Duration::from_millis(230)); + cancel::progress(page, 3, &format!("page {page}")); + } + return Ok(json!({"ran": id})); + } + let limit = if id.starts_with("quick") { Duration::ZERO } else { self.limit }; + let start = Instant::now(); + while start.elapsed() < limit { + if let Err(e) = cancel::check() { + self.log.lock().unwrap().push(format!("cancelled {id}")); + return Err(e.to_string()); + } + std::thread::sleep(Duration::from_millis(5)); + } + self.log.lock().unwrap().push(format!("done {id}")); + Ok(json!({"ran": id})) + } + + fn has_ui(&self) -> bool { + false + } + + fn describe(&self) -> String { + "slow".into() + } +} + +/// Collects the lines the server writes. +struct Lines(Sender, Vec); + +impl Write for Lines { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + for &b in buf { + if b == b'\n' { + self.0.send(String::from_utf8_lossy(&std::mem::take(&mut self.1)).into_owned()).ok(); + } else { + self.1.push(b); + } + } + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// A running server with a pipe in and a channel out. +struct Rig { + to_server: std::io::PipeWriter, + replies: Receiver, + started: Receiver, + log: Arc>>, + thread: Option>, +} + +impl Rig { + fn new(limit: Duration, busy_after: Duration) -> Self { + let (read, to_server) = std::io::pipe().unwrap(); + let (tx, replies) = mpsc::channel(); + let (started_tx, started) = mpsc::channel(); + let log = Arc::new(Mutex::new(vec![])); + let backend = Slow { limit, log: log.clone(), started: started_tx }; + let thread = std::thread::spawn(move || { + let mut server = Server::new(Box::new(backend)).with_busy_after(busy_after); + server.serve(BufReader::new(read), Lines(tx, vec![])).unwrap(); + }); + Self { to_server, replies, started, log, thread: Some(thread) } + } + + fn send(&mut self, msg: Value) { + writeln!(self.to_server, "{msg}").unwrap(); + } + + fn request(&mut self, id: u64, method: &str, params: Value) { + self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + } + + fn run(&mut self, id: u64, command: &str) { + self.request(id, "tools/call", json!({"name": "command_run", "arguments": {"id": command}})); + } + + fn notify(&mut self, method: &str, params: Value) { + self.send(json!({"jsonrpc": "2.0", "method": method, "params": params})); + } + + fn next(&self, within: Duration) -> Option { + match self.replies.recv_timeout(within) { + Ok(l) => serde_json::from_str(&l).ok(), + Err(RecvTimeoutError::Timeout) => None, + Err(RecvTimeoutError::Disconnected) => None, + } + } + + /// The next reply that answers request `id`, skipping notifications. + fn reply(&self, id: u64, within: Duration) -> Option { + let end = Instant::now() + within; + while let Some(left) = end.checked_duration_since(Instant::now()) { + let v = self.next(left)?; + if v.get("id") == Some(&json!(id)) { + return Some(v); + } + } + None + } + + fn finish(mut self) { + let thread = self.thread.take().unwrap(); + drop(self.to_server); + thread.join().unwrap(); + } +} + +const LONG: Duration = Duration::from_secs(20); +const QUICK: Duration = Duration::from_secs(3); + +#[test] +fn ping_is_answered_while_a_command_runs() { + let mut rig = Rig::new(LONG, Duration::from_millis(50)); + rig.run(1, "document.export"); + assert_eq!(rig.started.recv_timeout(QUICK).unwrap(), "document.export"); + // Past `busy_after` the call counts as long-running. + std::thread::sleep(Duration::from_millis(120)); + rig.request(2, "ping", json!({})); + let pong = rig.reply(2, QUICK).expect("ping must not wait for the running command"); + assert_eq!(pong["result"], json!({})); + rig.notify("notifications/cancelled", json!({"requestId": 1})); + rig.finish(); +} + +#[test] +fn cancelling_stops_the_running_command_and_sends_no_reply() { + let mut rig = Rig::new(LONG, Duration::from_secs(60)); + rig.run(1, "document.export"); + rig.started.recv_timeout(QUICK).unwrap(); + rig.notify("notifications/cancelled", json!({"requestId": 1, "reason": "user"})); + // The engine is free again: the next command runs and answers. + rig.run(2, "pdf.preset.list"); + let end = Instant::now() + QUICK; + loop { + if rig.log.lock().unwrap().iter().any(|l| l == "cancelled document.export") { + break; + } + assert!(Instant::now() < end, "the export kept running: {:?}", rig.log.lock().unwrap()); + std::thread::sleep(Duration::from_millis(10)); + } + rig.notify("notifications/cancelled", json!({"requestId": 2})); + // Request 1 was cancelled: nothing answers it (MCP: the receiver should not reply). + assert!(rig.reply(1, Duration::from_millis(300)).is_none()); + rig.finish(); +} + +#[test] +fn a_request_that_would_wait_behind_a_long_call_is_refused_as_busy() { + let mut rig = Rig::new(LONG, Duration::from_millis(50)); + rig.run(1, "document.export"); + rig.started.recv_timeout(QUICK).unwrap(); + std::thread::sleep(Duration::from_millis(120)); + rig.run(2, "pdf.preset.list"); + let busy = rig.reply(2, QUICK).expect("a busy answer, not a wait"); + assert_eq!(busy["error"]["code"], -32003, "{busy}"); + assert!(busy["error"]["message"].as_str().unwrap().contains("document.export"), "{busy}"); + assert_eq!(busy["error"]["data"]["busy"]["requestId"], "1", "{busy}"); + rig.notify("notifications/cancelled", json!({"requestId": 1})); + // The refused request never ran, and the engine is free once the export stops. + rig.run(3, "pdf.preset.list"); + rig.notify("notifications/cancelled", json!({"requestId": 3})); + rig.finish(); +} + +#[test] +fn quick_calls_pipelined_together_queue_and_answer_in_order() { + let mut rig = Rig::new(Duration::from_millis(30), Duration::from_secs(60)); + for id in 1..=4 { + rig.run(id, &format!("cmd{id}")); + } + for id in 1..=4 { + let r = rig.reply(id, QUICK).unwrap_or_else(|| panic!("no reply to {id}")); + assert!(r.get("error").is_none(), "{r}"); + } + assert_eq!(*rig.log.lock().unwrap(), ["done cmd1", "done cmd2", "done cmd3", "done cmd4"]); + rig.finish(); +} + +#[test] +fn a_queued_request_that_is_cancelled_never_runs() { + let mut rig = Rig::new(LONG, Duration::from_secs(60)); + rig.run(1, "document.export"); + rig.started.recv_timeout(QUICK).unwrap(); + rig.run(2, "never.runs"); + rig.run(3, "quick.last"); + // Request 2 may not have been read yet when the cancel is: it is the order of lines that counts. + rig.notify("notifications/cancelled", json!({"requestId": 2})); + rig.notify("notifications/cancelled", json!({"requestId": 1})); + let r = rig.reply(3, QUICK).expect("the engine goes on to the next request"); + assert!(r.get("error").is_none(), "{r}"); + rig.notify("notifications/cancelled", json!({"requestId": 3})); + let log = rig.log.lock().unwrap().clone(); + assert!(log.iter().all(|l| !l.contains("never.runs")), "{log:?}"); + assert!(log.iter().any(|l| l == "cancelled document.export"), "{log:?}"); + drop(log); + rig.finish(); +} + +#[test] +fn cancelling_an_unknown_or_finished_request_changes_nothing() { + let mut rig = Rig::new(Duration::from_millis(10), Duration::from_secs(60)); + rig.run(1, "quick"); + assert!(rig.reply(1, QUICK).unwrap().get("result").is_some()); + rig.notify("notifications/cancelled", json!({"requestId": 1})); + rig.notify("notifications/cancelled", json!({"requestId": 99})); + rig.notify("notifications/cancelled", json!({"requestId": [1], "reason": "junk"})); + rig.notify("notifications/cancelled", json!("junk")); + rig.run(2, "quick"); + assert!(rig.reply(2, QUICK).unwrap().get("result").is_some()); + rig.finish(); +} + +#[test] +fn initialize_cannot_be_cancelled() { + let mut rig = Rig::new(LONG, Duration::from_secs(60)); + rig.request(1, "initialize", json!({})); + rig.notify("notifications/cancelled", json!({"requestId": 1})); + assert!(rig.reply(1, QUICK).unwrap().get("result").is_some()); + rig.finish(); +} + +#[test] +fn progress_reaches_a_client_that_gave_a_token_before_the_reply() { + let mut rig = Rig::new(LONG, Duration::from_secs(60)); + rig.request(1, "tools/call", json!({"name": "command_run", "arguments": {"id": "progress.demo"}, "_meta": {"progressToken": "tok"}})); + let mut seen = vec![]; + let reply = loop { + let v = rig.next(QUICK).expect("a reply"); + if v["method"] == "notifications/progress" { + seen.push(v["params"].clone()); + } else { + break v; + } + }; + assert!(reply.get("result").is_some(), "{reply}"); + assert_eq!(seen.len(), 3, "{seen:?}"); + let values: Vec = seen.iter().map(|p| p["progress"].as_u64().unwrap()).collect(); + assert_eq!(values, [1, 2, 3]); + assert!(seen.iter().all(|p| p["progressToken"] == "tok" && p["total"] == 3), "{seen:?}"); + assert_eq!(seen[1]["message"], "page 2"); + // Without a token nothing is sent. + rig.run(2, "progress.demo"); + let next = rig.next(QUICK).unwrap(); + assert_eq!(next["id"], 2, "{next}"); + rig.finish(); +} + +/// The real engine behind the server: waits (like a slow render would) until the call is +/// cancelled, then lets the export run into the cancelled token. +struct Gated { + inner: crate::Headless, + started: Sender<()>, +} + +impl Backend for Gated { + fn call(&mut self, method: &str, params: Value) -> Result { + if method == "engine.execute" && params["command"] == "document.export" { + self.started.send(()).ok(); + let end = Instant::now() + LONG; + while !cancel::is_cancelled() && Instant::now() < end { + std::thread::sleep(Duration::from_millis(5)); + } + } + self.inner.call(method, params) + } + + fn has_ui(&self) -> bool { + false + } + + fn describe(&self) -> String { + "gated".into() + } +} + +#[test] +fn a_cancelled_export_through_the_real_engine_writes_nothing_and_leaves_the_session_usable() { + let path = vectorcraft_testkit::temp_dir("mcp-cancel").join("out.pdf"); + std::fs::remove_file(&path).ok(); + let (read, mut to_server) = std::io::pipe().unwrap(); + let (tx, replies) = mpsc::channel(); + let (started_tx, started) = mpsc::channel(); + let thread = std::thread::spawn(move || { + let backend = Gated { inner: crate::Headless::with_document(), started: started_tx }; + Server::new(Box::new(backend)).serve(BufReader::new(read), Lines(tx, vec![])).unwrap(); + }); + let send = |w: &mut std::io::PipeWriter, v: Value| writeln!(w, "{v}").unwrap(); + let params = json!({"name": "command_run", "arguments": {"id": "document.export", "params": {"path": path.to_str().unwrap()}}}); + send(&mut to_server, json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": params})); + started.recv_timeout(QUICK).unwrap(); + send(&mut to_server, json!({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}})); + // The next request is answered: the cancelled export neither blocks nor breaks the engine. + send(&mut to_server, json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "doc_inspect", "arguments": {}}})); + let reply: Value = serde_json::from_str(&replies.recv_timeout(QUICK).unwrap()).unwrap(); + assert_eq!(reply["id"], 2, "request 1 is not answered: {reply}"); + assert_eq!(reply["result"]["isError"], false, "{reply}"); + assert!(!path.exists(), "a cancelled export writes no file"); + drop(to_server); + thread.join().unwrap(); +} + +#[test] +fn a_remote_call_stops_waiting_for_the_app_when_cancelled() { + use std::io::BufRead; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + // An app that takes the request and never answers. + let app = std::thread::spawn(move || { + let (conn, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(conn.try_clone().unwrap()).read_line(&mut line).unwrap(); + // Hold the connection open until the test is done with it. + std::thread::sleep(Duration::from_secs(3)); + drop(conn); + }); + let mut remote = crate::Remote::connect(&addr).unwrap(); + let token = cancel::CancelToken::new(); + let raise = token.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(200)); + raise.cancel(); + }); + let start = Instant::now(); + let r = cancel::scope(&token, None, || remote.call("engine.execute", json!({"command": "document.export"}))); + let why = r.unwrap_err(); + assert!(why.contains("cancelled"), "{why}"); + assert!(start.elapsed() < Duration::from_secs(2), "returned when cancelled, not when the app answered"); + app.join().unwrap(); +} diff --git a/crates/mcp/tests/protocol_props.rs b/crates/mcp/tests/protocol_props.rs index 7aad0fb9d..9605a0ca7 100644 --- a/crates/mcp/tests/protocol_props.rs +++ b/crates/mcp/tests/protocol_props.rs @@ -617,3 +617,64 @@ fn tools_never_leave_interactions_open() { } assert!(failures.is_empty(), "{}", failures.join("\n")); } + +proptest! { + #![proptest_config(ProptestConfig { cases: 48, ..ProptestConfig::default() })] + + /// Requests mixed with `notifications/cancelled` (for ids that are queued, running, finished, + /// unknown or junk) read from one stream: the server ends, every request nobody cancelled is + /// answered exactly once, a cancelled one at most once, nothing is answered that wasn't asked, + /// replies keep the order of the requests, and the document stays valid. + #[test] + fn cancellation_never_loses_or_duplicates_an_answer( + steps in prop::collection::vec((0u8..6, 0u64..10), 1..40), + ) { + let mut input = String::new(); + let mut asked: Vec = vec![]; + let mut cancelled: Vec = vec![]; + for (i, &(kind, n)) in steps.iter().enumerate() { + let id = i as u64 + 1; + let line = match kind { + 0 => json!({"jsonrpc": "2.0", "id": id, "method": "ping"}), + 1 => json!({"jsonrpc": "2.0", "id": id, "method": "tools/list"}), + 2 => json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": "draw_shape", "arguments": {"shape": "rectangle", "x": 1, "y": 2, "width": 30, "height": 20}}}), + 3 => json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": "command_run", "arguments": {"id": "select.all"}}, "_meta": {"progressToken": id}}), + // A cancel for an id that may be queued, finished or never asked. + 4 => { + let target = n.min(id.saturating_sub(1)).max(1); + cancelled.push(target); + input.push_str(&format!("{}\n", json!({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": target}}))); + continue; + } + // Junk cancels never take anything with them. + _ => { + let junk = [json!(null), json!([1]), json!({"a": 1}), json!("nope"), json!(1.5), json!(-1)]; + input.push_str(&format!("{}\n", json!({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": junk[n as usize % junk.len()]}}))); + continue; + } + }; + asked.push(id); + input.push_str(&format!("{line}\n")); + } + let mut s = server(); + let mut out = Vec::new(); + s.serve(input.as_bytes(), &mut out).unwrap(); + let ids: Vec = String::from_utf8(out) + .unwrap() + .lines() + .map(|l| serde_json::from_str::(l).unwrap()) + .filter(|v| v.get("method").is_none()) + .map(|v| v["id"].as_u64().unwrap()) + .collect(); + prop_assert!(ids.windows(2).all(|w| w[0] < w[1]), "answered once each, in request order: {:?}", ids); + for id in &asked { + if !cancelled.contains(id) { + prop_assert!(ids.contains(id), "request {} was never answered: {:?}", id, ids); + } + } + for id in &ids { + prop_assert!(asked.contains(id), "answer to a request nobody made: {}", id); + } + check_document(&document(&mut s)).unwrap(); + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 1da3b3710..7d729846e 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -44,8 +44,11 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result Result { let set = &opts.settings; set.check()?; + vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; // Live geometry effects export as their result; raster effects are reported below. let baked = vectorcraft_effects::bake_document(doc); + // Baking stops early when cancelled, leaving a half-baked copy that must not be written. + vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; let doc = baked.as_ref().unwrap_or(doc); let pages = pages(doc, opts)?; @@ -77,7 +80,11 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions) -> Result Result **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make @@ -283,6 +283,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Missing:** a scripting surface over the command registry (Illustrator: JavaScript), Batch, graph variable kinds. Actions, Variables (text, visibility and image), dataset CSV import/export and full agent control over kinds, dataset import/export. Actions, Variables (text, visibility and image) and full agent control over +- **Done (2026-10-16):** the MCP server hears the client during a long call: `notifications/cancelled` stops a queued or running export (PDF pages, flattening, baking, drawing, artboards) without writing anything or changing the document, `ping` and the list methods answer at once, any other request that would wait behind a call running for two seconds or more is refused as busy (`-32003`, naming the call), and `_meta.progressToken` gets page progress (#2165, #2171, #2172). Reading the document during an export is not served (refused as busy); estimate unchanged. - **Exposed in MCP:** `add_text {vertical: true}` uses the existing vertical point, area and path type commands. Horizontal writing remains the default; the broader automation gap and estimate are unchanged. - **Missing:** a scripting surface over the command registry (Illustrator: JavaScript), Batch, image and graph variable kinds, dataset import/export. Actions, Variables (text and visibility) and full agent control over @@ -335,6 +336,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | G6: MCP cancellation, progress notifications and a busy answer during long exports (#2165, #2171, #2172); no entry closed, estimates unchanged | | 2026-10-16 | minor | G11: PDF export of a CMYK document reports its RGB colours and non-CMYK images, with a count, in the export warnings (#2162); no entry closed, estimates unchanged | | 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Correction to G12: quick Image Trace presets trace at once and leave the panel as it was; the Image Trace button in the Control bar or Properties opens the controls (). Wording only; no entry opened or closed, estimates unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 5d8f0c204..e0f2fb9e5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -225,36 +225,57 @@ crate that installed one would override whatever logger an embedder had already `vectorcraft-mcp` directly means calling `vectorcraft_mcp::logging::install()` yourself if you want log records to reach your client. -### Synchronous calls +### Long calls: cancellation, progress and a busy engine -Exports complete synchronously. A `_meta.progressToken` is harmlessly ignored, as are -`notifications/cancelled` (including unknown request ids). There are no background export jobs -or progress notifications; cancellation cannot interrupt a running call. +A call runs to the end before its reply, and the engine runs one call at a time, in the order the +lines arrived. A thread of its own reads the stream meanwhile, so the server still hears the client +while an export runs (#2165, #2171, #2172): + +- **Cancellation.** `notifications/cancelled {requestId}` + ([spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation)) + takes a request out of the queue, or stops the one that is running, and **that request gets no + reply**. A running export, flatten, bake or render notices at its next checkpoint (between pages + and artboards, between flattened groups, while drawing objects), returns, and writes nothing: the + document is exactly as it was and the engine is free for the next call. An unknown, finished or + malformed `requestId` is ignored, and so is a cancel of `initialize`. A call without checkpoints + (every ordinary edit is over in milliseconds) runs to its end, but its reply is dropped all the + same. Against a running app (`--connect`) the server stops waiting and drops the connection, but + the app cannot be told to stop what it started. +- **Progress.** A request with `_meta.progressToken` gets `notifications/progress` + ([spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress)) before + its reply: the PDF writer reports "Writing page 2 of 5" with `progress` 2 and `total` 5, raster + export reports each artboard, and the PDF flattening stage reports a message without a total. `progress` only grows, updates come at most every 200 ms (the last page always), + and none follows the reply. A client that does not read fast enough misses updates, never the reply. +- **A busy engine.** `ping`, `tools/list`, `resources/list`, `resources/templates/list`, + `prompts/list` and `prompts/get` need no engine and are answered at once, whatever is running. + Anything else that arrives while a call has already run for two seconds is not queued behind it: + it is answered with error `-32003` (`data.busy` names the running call, its request id and how + long it has run) and was **not run**, so the client can retry later or cancel the running call. + Requests that arrive sooner than that queue and are answered in order, as before, so a client + that sends a few quick calls together sees no difference. At most 64 lines wait; past that the + server stops reading until the engine catches up. + +`vectorcraft_mcp::Server::serve` takes the input and the output on separate threads (`Send`), and +`Server::with_busy_after` changes the two seconds. Calling `handle_line` directly is as before: +one line in, one reply out, no threads. ### Not implemented -Everything below needs the same thing first: the server reads one line at a time and answers it with -one reply, so **while a `tools/call` is running it neither reads an incoming line nor writes an -outgoing one.** Progress and cancellation are therefore both meaningless as they stand, and a -subscription could only ever be checked between calls — which is not what a client asking to be told -about a change expects. - - **`resources/subscribe` / `notifications/resources/updated`.** It would also have to be honest about *whose* edits it sees: comparing the undo history's length misses edits made in the app window, which is the main reason to run against a live document. `document.inspect`'s `revision` is the right signal — it is monotonic and already bumped when a document is replaced — but noticing an edit still means someone asking. -- **`notifications/progress`.** It would have to be written *before* the reply it belongs to, since a - progress token stops meaning anything once the request has completed. -- **`notifications/cancelled`.** It would have to be read *during* the call it cancels. -- **`sampling/createMessage` and `elicitation/create`.** Both are server→client requests, blocked by - the same one-line-one-reply loop. Supporting them means being able to suspend a tool call - mid-flight, which is what the `2026-07-28` revision's input-requests mechanism exists for. +- **Reading the document during a long call.** Requests that need the engine (`doc_inspect`, + `resources/read`, `command_list`) are refused as busy while an export runs, not answered from a + snapshot. +- **`sampling/createMessage` and `elicitation/create`.** Both are server→client requests. Supporting + them means being able to suspend a tool call mid-flight, which is what the `2026-07-28` revision's + input-requests mechanism exists for. - **Streamable HTTP / SSE.** stdio only. `completion/complete` covers the "what could I pass here" half of the same problem, which is why it -is here and subscriptions are not. The fix for the rest is a reader thread (or an event loop) around -the transport — a design change rather than a feature, so it is not in this crate yet. +is here and subscriptions are not. ## Tools diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b35db0fb8..f8f68e48a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), @@ -418,7 +418,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - JSON-lines control channel with real egui pointer and keyboard injection. - Synthetic input validates complete gestures before queue mutation: at most 256 drag steps, 16 clicks and 1,024 pending events; up to 64 control requests per frame, with later requests retained for the next frame (#1709). - The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s. - - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. 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). + - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. 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). 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). - Actions panel that records and plays back commands. @@ -690,6 +690,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Agent automation: MCP cancellation, progress notifications and busy answers during long exports (#2165, #2171, #2172); scores and hours unchanged | | 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Type: localized legacy family names remain available when typographic names are English-only (#2208); loaded and catalog paths tested. Scores and estimates unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | From a8e45f0e2d1f30140b1bf3d5296de05e1023a38a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 22:45:24 +0200 Subject: [PATCH 069/208] Persistence: saved PDF presets and loaded ICC profiles survive a restart (#2168, #2174) PDF presets are kept one file each in a PDF Presets folder, ICC profiles as copies in a Color Profiles folder, next to the preferences; the desktop app and vectorcraft-cli use them. Adds color.removeProfile. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft-cli/src/main.rs | 15 +- apps/vectorcraft-cli/tests/cli.rs | 94 +++++++++++++ apps/vectorcraft/src/main.rs | 8 +- apps/vectorcraft/src/prefs_dir.rs | 25 ++++ crates/color/src/cms/mod.rs | 22 ++- crates/engine/src/cmd/colormgmt.rs | 40 +++++- crates/engine/src/cmd/pdfcmds.rs | 22 ++- crates/engine/src/lib.rs | 11 ++ crates/engine/src/store.rs | 211 ++++++++++++++++++++++++++++ crates/engine/src/tests_store.rs | 128 +++++++++++++++++ crates/ui-egui/src/i18n/bg.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/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.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/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/development.md | 3 +- docs/mcp.md | 25 +++- 31 files changed, 605 insertions(+), 18 deletions(-) create mode 100644 crates/engine/src/store.rs create mode 100644 crates/engine/src/tests_store.rs diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index b843af67f..8d09dcb68 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -159,6 +159,15 @@ fn main() -> ExitCode { } } +/// A headless session that remembers what the user keeps between runs: the PDF presets saved and +/// the ICC profiles loaded (#2168, #2174), in the folders next to the preferences that the desktop +/// app uses too. `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply as they do there. +fn with_stores(mut h: Headless) -> Headless { + prefs_dir::attach_profile_store(&mut h.session); + prefs_dir::attach_pdf_preset_store(&mut h.session); + h +} + fn mcp(args: &[String]) -> Result<(), String> { let mut connect: Option = None; let mut headless = false; @@ -198,14 +207,14 @@ fn mcp(args: &[String]) -> Result<(), String> { eprintln!("vectorcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root())); Box::new(Headless::with_document().with_automation_roots(Some(roots))) } else if headless { - Box::new(Headless::with_document()) + Box::new(with_stores(Headless::with_document())) } else if let Some(addr) = connect { // Explicit address: fail loudly if the app isn't there. Box::new(Remote::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?) } else { match Remote::connect(DEFAULT_ADDR) { Ok(r) => Box::new(r), - Err(_) => Box::new(Headless::with_document()), + Err(_) => Box::new(with_stores(Headless::with_document())), } }; eprintln!("vectorcraft-cli: MCP server on stdio ({})", backend.describe()); @@ -327,7 +336,7 @@ fn run(args: &[String]) -> Result<(), String> { } } - let mut h = Headless::new(); + let mut h = with_stores(Headless::new()); let mut out = std::io::stdout().lock(); // A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later // steps (an export…) still run, unprinted. diff --git a/apps/vectorcraft-cli/tests/cli.rs b/apps/vectorcraft-cli/tests/cli.rs index 133c51df1..32c5e7f09 100644 --- a/apps/vectorcraft-cli/tests/cli.rs +++ b/apps/vectorcraft-cli/tests/cli.rs @@ -367,3 +367,97 @@ fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() { let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap(); assert!(!out.status.success()); } + +/// A fresh settings folder for one test (`VECTORCRAFT_CONFIG_DIR`), so no run reads or writes the +/// developer's real profile. +fn config_dir(name: &str) -> std::path::PathBuf { + let dir = tmp(&format!("config-{name}")); + let _ = std::fs::remove_dir_all(&dir); + dir +} + +/// One `vectorcraft-cli run` with `steps` ((command, params)) against the settings folder `config`; +/// the result of each step. +fn run_in(config: &std::path::Path, steps: &[(&str, Value)]) -> Vec { + let mut cmd = Command::new(BIN); + cmd.arg("run").env("VECTORCRAFT_CONFIG_DIR", config).env_remove("VECTORCRAFT_NO_PREFS"); + for (id, params) in steps { + cmd.args(["--cmd", id, "--params", ¶ms.to_string()]); + } + let out = cmd.output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str::(l).unwrap()["result"].clone()).collect() +} + +/// An ICC file of an RGB profile named `name` (a built-in one with its description replaced by a +/// name of the same length). +fn icc_file_named(path: &std::path::Path, name: &str) { + let builtin = "Display P3"; + assert_eq!(builtin.len(), name.len()); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + let mut bytes = vectorcraft_color::cms::icc_bytes(builtin).unwrap().to_vec(); + let (from, to) = (utf16(builtin), utf16(name)); + let at = bytes.windows(from.len()).position(|w| w == from).expect("the description in the profile"); + bytes[at..at + to.len()].copy_from_slice(&to); + std::fs::write(path, bytes).unwrap(); +} + +fn profile_names(settings: &Value) -> Vec<&str> { + settings["profiles"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect() +} + +/// #2168, #2174: the PDF presets saved and the ICC profiles loaded in one headless run are there in +/// the next run, and gone again once deleted. +#[test] +fn saved_pdf_presets_and_loaded_profiles_survive_a_restart() { + let config = config_dir("restart"); + let icc = tmp("studio-p3.icc"); + icc_file_named(&icc, "StudioRGB3"); + let r = run_in( + &config, + &[ + ("pdf.preset.save", json!({"name": "Press", "description": "for the printer"})), + ("color.loadProfile", json!({"path": icc.to_str().unwrap()})), + ], + ); + assert_eq!(r[1]["name"], "StudioRGB3"); + + let r = run_in(&config, &[("pdf.preset.list", json!({})), ("edit.colorSettings", json!({}))]); + let presets: Vec<&str> = + r[0]["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == false).map(|p| p["name"].as_str().unwrap()).collect(); + assert_eq!(presets, ["Press"]); + assert_eq!(r[0]["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Press").unwrap()["description"], "for the printer"); + assert!(profile_names(&r[1]).contains(&"StudioRGB3"), "{:?}", profile_names(&r[1])); + // The profile works as a working space, and the file it came from can go. + std::fs::remove_file(&icc).unwrap(); + let steps = [ + ("edit.colorSettings", json!({"rgb": "StudioRGB3"})), + ("pdf.preset.delete", json!({"name": "Press"})), + ("edit.colorSettings", json!({"rgb": vectorcraft_color::cms::SRGB})), + ("color.removeProfile", json!({"name": "StudioRGB3"})), + ]; + let r = run_in(&config, &steps); + assert_eq!(r[0]["rgb"], "StudioRGB3"); + assert_eq!(r[3]["removed"], "StudioRGB3"); + + let r = run_in(&config, &[("pdf.preset.list", json!({})), ("edit.colorSettings", json!({}))]); + assert!(r[0]["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true)); + assert!(!profile_names(&r[1]).contains(&"StudioRGB3")); +} + +/// Runs without preferences (`VECTORCRAFT_NO_PREFS`) keep what they save for the run only, and +/// leave nothing in the settings folder. +#[test] +fn runs_without_preferences_remember_nothing() { + let config = config_dir("noprefs"); + let out = Command::new(BIN) + .args(["run", "--cmd", "pdf.preset.save", "--params", r#"{"name":"Press"}"#]) + .env("VECTORCRAFT_CONFIG_DIR", &config) + .env("VECTORCRAFT_NO_PREFS", "1") + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!(!config.exists(), "nothing written"); + let r = run_in(&config, &[("pdf.preset.list", json!({}))]); + assert!(r[0]["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true)); +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 23aaea179..6046bcaa6 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -854,8 +854,14 @@ fn main() -> std::process::ExitCode { "VectorCraft", options, Box::new(move |cc| { - let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned(), cc.egui_ctx.clone())); + let mut session = Session::new(); + // The profiles loaded before come back before the preferences name any of them. + prefs_dir::attach_profile_store(&mut session); + let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone())); load_prefs(&mut app, saved); + // The saved PDF presets live in their own folder (also read by vectorcraft-cli), + // which takes over from the copy the preferences file used to hold. + prefs_dir::attach_pdf_preset_store(&mut app.session); // Fit the window to its monitor, or put it back where it was (still hidden). if let Some(w) = cc.winit_window() { app.ui.window = Some(window::restore(w, saved_window)); diff --git a/apps/vectorcraft/src/prefs_dir.rs b/apps/vectorcraft/src/prefs_dir.rs index 4dd258afa..00298342d 100644 --- a/apps/vectorcraft/src/prefs_dir.rs +++ b/apps/vectorcraft/src/prefs_dir.rs @@ -24,6 +24,31 @@ pub fn install_fonts() { } } +/// The folder `name` next to the settings, for what the user keeps: none in a run without +/// preferences (`VECTORCRAFT_NO_PREFS`), which keeps nothing. +fn store_dir(name: &str) -> Option { + if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() { + return None; + } + Some(prefs_path_for("VectorCraft", "vectorcraft")?.parent()?.join(name)) +} + +/// Register the ICC profiles `color.loadProfile` kept in the `Color Profiles` folder (#2174), and +/// keep the ones loaded from now on there. Before anything names a profile. +pub fn attach_profile_store(session: &mut vectorcraft_engine::Session) { + if let Some(dir) = store_dir("Color Profiles") { + session.attach_profile_store(dir); + } +} + +/// Take the saved PDF presets from the `PDF Presets` folder (#2168), and keep the ones saved from +/// now on there. After the preferences were restored: the folder is the one place the presets live. +pub fn attach_pdf_preset_store(session: &mut vectorcraft_engine::Session) { + if let Some(dir) = store_dir("PDF Presets") { + session.attach_pdf_preset_store(dir); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index 9eeafdb2c..f2a705857 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -257,9 +257,25 @@ pub fn register_icc(bytes: &[u8], name: Option) -> Result bool { + let mut u = USER.write().unwrap_or_else(|e| e.into_inner()); + let before = u.len(); + u.retain(|x| x.name != name); + let removed = u.len() != before; + drop(u); + if removed { + clear_proof_cache(); + PROFILES_GENERATION.fetch_add(1, Ordering::Relaxed); + } + removed +} + +/// A number that changes whenever [`register_icc`] registers a profile or [`unregister_icc`] +/// removes one: conversions through a profile looked up by name, such as those of a +/// [`ProofTarget::Cmyk`] proof, give the same colors as long as the number stays the same. pub fn profiles_generation() -> u64 { PROFILES_GENERATION.load(Ordering::Relaxed) } diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index f034d6164..91afee24c 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -39,10 +39,19 @@ pub fn specs() -> Vec { "Load Color Profile…", [], None, - "{path: .icc/.icm file} register an ICC profile until the app quits or the `run` ends (native only; it isn't remembered, so load it again in each run that names it) → {name, kind}", + "{path: .icc/.icm file of at most 32 MB} register an ICC profile (native only). The desktop app and vectorcraft-cli keep a copy of the file in the `Color Profiles` folder next to the preferences and register it again at every start (not with VECTORCRAFT_NO_PREFS, or in a confined `mcp --automation-*-root` server: there it lasts until the process ends) → {name, kind, remembered: whether a copy was kept}", always, load_profile ), + cmd!( + "color.removeProfile", + "Remove Color Profile…", + [], + None, + "{name} forget a profile color.loadProfile loaded, and the copy kept of it (built-in profiles and the working spaces stay) → {removed: name}", + always, + remove_profile + ), cmd!( "edit.assignProfile", "Assign Profile…", @@ -178,12 +187,35 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { } #[cfg(not(target_arch = "wasm32"))] -fn load_profile(_: &mut Session, p: &Value) -> Result { +fn load_profile(s: &mut Session, p: &Value) -> Result { const C: &str = "color.loadProfile"; let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?; crate::file_access::check_read(path).map_err(EngineError::Other)?; - let info = cms::load_icc_file(std::path::Path::new(path)).map_err(|e| bad(C, e.to_string()))?; - Ok(json!({"name": info.name, "kind": info.kind})) + let bytes = crate::store::read_capped(std::path::Path::new(path), crate::store::MAX_PROFILE_FILE).map_err(|e| bad(C, format!("{path}: {e}")))?; + let info = cms::register_icc(&bytes, None).map_err(|e| bad(C, e.to_string()))?; + // The bytes are kept, not the path: the file may move or go away. + let remembered = s.store_profile(&info.name, &bytes); + Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered})) +} + +/// Forget a profile `color.loadProfile` loaded, and the copy that is kept of it. +fn remove_profile(s: &mut Session, p: &Value) -> Result { + const C: &str = "color.removeProfile"; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; + let info = cms::profiles().into_iter().find(|i| i.name == name).ok_or_else(|| bad(C, format!("no profile named `{name}`")))?; + if info.builtin { + return Err(bad(C, format!("`{name}` is a built-in profile and stays"))); + } + let working = cms::active_settings(); + if working.rgb == name || working.cmyk == name { + return Err(bad(C, format!("`{name}` is a working space: choose another with edit.colorSettings first"))); + } + if !cms::unregister_icc(&info.name) { + return Err(bad(C, format!("no profile named `{name}`"))); + } + s.unstore_profile(&info.name); + touch_all(s); + Ok(json!({"removed": info.name})) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/cmd/pdfcmds.rs b/crates/engine/src/cmd/pdfcmds.rs index fb63257b1..faa986795 100644 --- a/crates/engine/src/cmd/pdfcmds.rs +++ b/crates/engine/src/cmd/pdfcmds.rs @@ -18,10 +18,16 @@ pub const PRESET_EXTS: &[&str] = &[PRESET_FORMAT]; /// What `pdf.preset.export` writes and `pdf.preset.import` reads. #[derive(Serialize, Deserialize)] -struct PresetFile { - format: String, +pub(crate) struct PresetFile { + pub(crate) format: String, #[serde(default)] - presets: Vec, + pub(crate) presets: Vec, +} + +impl PresetFile { + pub(crate) fn new(presets: Vec) -> Self { + Self { format: PRESET_FORMAT.into(), presets } + } } pub fn specs() -> Vec { @@ -112,6 +118,7 @@ fn save(s: &mut Session, p: &Value) -> Result { } let at = s.pdf_saved_index(&name); let saved = at.and_then(|i| s.prefs.pdf_presets.get(i)).cloned(); + let old_name = saved.as_ref().map(|old| old.name.clone()); // A saved preset changes from its own settings unless `preset` says where to start. let mut q = if p.is_object() { p.clone() } else { json!({}) }; if let (Some(old), None) = (&saved, str_param(p, "preset")) { @@ -127,6 +134,7 @@ fn save(s: &mut Session, p: &Value) -> Result { }; let description = str_param(p, "description").map(str::to_string).or_else(|| saved.map(|old| old.description)).unwrap_or_default(); let preset = PdfPreset { name: name.clone(), description, settings: settings.clone() }; + s.store_pdf_preset(old_name.as_deref(), &preset); match at.and_then(|i| s.prefs.pdf_presets.get_mut(i)) { Some(slot) => *slot = preset, None => s.prefs.pdf_presets.push(preset), @@ -141,7 +149,9 @@ fn delete(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("`{name}` is a built-in preset and stays"))); } let i = s.pdf_saved_index(name).ok_or_else(|| bad(C, format!("no saved PDF preset named `{name}`")))?; - Ok(json!({ "deleted": s.prefs.pdf_presets.remove(i).name })) + let deleted = s.prefs.pdf_presets.remove(i).name; + s.unstore_pdf_preset(&deleted); + Ok(json!({ "deleted": deleted })) } fn export(s: &mut Session, p: &Value) -> Result { @@ -196,12 +206,14 @@ fn import(s: &mut Session, p: &Value) -> Result { if let Some(slot) = s.prefs.pdf_presets.get_mut(i) { preset.name = slot.name.clone(); imported.push(preset.name.clone()); - *slot = preset; + *slot = preset.clone(); + s.store_pdf_preset(Some(&preset.name), &preset); } } None => { preset.name = unique_name(&base, |n| s.pdf_preset_taken(n, None)); imported.push(preset.name.clone()); + s.store_pdf_preset(None, &preset); s.prefs.pdf_presets.push(preset); } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 30ffda9f4..15ade244c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,7 @@ pub mod journal; pub mod link_watch; pub mod namematch; pub mod steps; +pub mod store; mod tooling; pub mod units; @@ -1176,6 +1177,12 @@ pub struct Session { pub style_libraries: cmd::stylelib::Libraries, /// The Libraries panel's libraries of graphics, colours and text styles (`library.*`). pub libraries: cmd::library::Libraries, + /// Where the saved PDF presets are kept, one file each; `None`: only for the session + /// ([`Session::attach_pdf_preset_store`]). + pdf_preset_dir: Option, + /// Where the ICC profiles `color.loadProfile` loads are kept; `None`: only for the process + /// ([`Session::attach_profile_store`]). + profile_dir: Option, /// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved. pub recent_urls: Vec, /// The language the UI is drawn in (a language code, never `auto`), set by the UI each frame; @@ -1268,6 +1275,8 @@ impl Session { freeform_point: None, style_libraries: Default::default(), libraries: Default::default(), + pdf_preset_dir: None, + profile_dir: None, recent_urls: vec![], ui_language: None, journal_note: Default::default(), @@ -2130,6 +2139,8 @@ mod tests_slices; #[cfg(test)] mod tests_smartguides; #[cfg(test)] +mod tests_store; +#[cfg(test)] mod tests_strokegeom; #[cfg(test)] mod tests_strokegradient; diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs new file mode 100644 index 000000000..9de359b44 --- /dev/null +++ b/crates/engine/src/store.rs @@ -0,0 +1,211 @@ +//! The user's own PDF presets and ICC profiles, kept in folders next to the preferences so that +//! they outlive the process (#2168, #2174). The apps decide where ([`Session::attach_pdf_preset_store`], +//! [`Session::attach_profile_store`]); a session nobody attached a folder to keeps nothing, which +//! is how the engine's own tests and embedders run. +//! +//! * **PDF presets**: one `.vcpdfpresets` file per preset (the format `pdf.preset.export` writes, +//! so a file can also be imported by hand), named after the preset. A session that attaches the +//! folder replaces its saved presets with the ones in it; a folder that doesn't exist yet takes +//! the presets the session has (the ones an older version kept in the preferences file). +//! * **ICC profiles**: the bytes of each profile `color.loadProfile` registered, as `.icc` files +//! named after the profile, never the path they came from (a profile in a system folder may +//! move or go away). Attaching the folder registers them again. +//! +//! The files are untrusted input like any other: sizes and counts are capped, a file that does not +//! parse is skipped with a log record, and every write is atomic. + +use std::io::Read; +use std::path::{Path, PathBuf}; + +use vectorcraft_pdf::{PdfPreset, builtin_preset}; + +use crate::Session; +use crate::cmd::pdfcmds::{PRESET_FORMAT, PresetFile}; + +/// Files read from one store folder, at most (the rest are ignored). +pub const MAX_STORE_FILES: usize = 1000; +/// The largest PDF presets file read (a preset is a few kilobytes). +pub const MAX_PRESET_FILE: u64 = 1 << 20; +/// The largest ICC profile file read or loaded (CMYK profiles with large tables are a few MB). +pub const MAX_PROFILE_FILE: u64 = 32 << 20; +/// The extension of a stored ICC profile. +const PROFILE_EXT: &str = "icc"; + +/// The file name (without extension) a stored item called `name` gets: its name made safe for any +/// file system, then a hash of the name, so that names that differ only in characters a file name +/// can't hold, or in case, can't share a file. Names equal in any case are one item. +pub(crate) fn file_stem(name: &str) -> String { + let safe: String = name.trim().chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_') { c } else { '_' }).take(48).collect(); + // FNV-1a over the lower-cased name: stable across versions and platforms, unlike `DefaultHasher`. + let hash = name.trim().to_lowercase().bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)); + format!("{}-{hash:016x}", safe.trim()) +} + +/// The regular files of `dir` with the extension `ext` (any case), by file name, at most +/// [`MAX_STORE_FILES`]; none when the folder can't be read. +fn list_files(dir: &Path, ext: &str) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { return vec![] }; + let mut files: Vec = entries + .filter_map(Result::ok) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext)) && p.is_file()) + .collect(); + files.sort_by_key(|p| p.file_name().map(|n| n.to_string_lossy().to_lowercase())); + files.truncate(MAX_STORE_FILES); + files +} + +/// The content of `path` when it is at most `max` bytes; the file is never read past `max`. +pub(crate) fn read_capped(path: &Path, max: u64) -> Result, String> { + let file = std::fs::File::open(path).map_err(|e| e.to_string())?; + let mut bytes = vec![]; + file.take(max + 1).read_to_end(&mut bytes).map_err(|e| e.to_string())?; + if bytes.len() as u64 > max { + return Err(too_large(max)); + } + Ok(bytes) +} + +/// What a file over its cap is told (a detail after the file's name, in the log or an error). +fn too_large(max: u64) -> String { + format!("larger than {max} bytes") +} + +/// Write `bytes` to `dir/file`, making the folder; nothing is written when the file has them +/// already. A failure is logged: remembering is best effort and never fails the command. +fn put(dir: &Path, file: &str, bytes: &[u8]) -> bool { + let path = dir.join(file); + if std::fs::read(&path).is_ok_and(|old| old == bytes) { + return true; + } + let written = std::fs::create_dir_all(dir).and_then(|()| write_atomic(&path, bytes)); + if let Err(e) = &written { + log::warn!("can't save {}: {e}", path.display()); + } + written.is_ok() +} + +#[cfg(not(target_arch = "wasm32"))] +fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + vectorcraft_format::write_atomic(path, bytes) +} + +/// The browser has no folders to keep anything in (the apps there never attach a store). +#[cfg(target_arch = "wasm32")] +fn write_atomic(_: &Path, _: &[u8]) -> std::io::Result<()> { + Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no file system")) +} + +/// Remove `dir/file`; a file that isn't there is what was wanted. +fn take(dir: &Path, file: &str) { + let path = dir.join(file); + if let Err(e) = std::fs::remove_file(&path) + && e.kind() != std::io::ErrorKind::NotFound + { + log::warn!("can't remove {}: {e}", path.display()); + } +} + +impl Session { + /// Keep the PDF presets this session saves in `dir`, and take the ones already there as the + /// saved presets (see the module docs). Call it once, after the preferences were restored. + pub fn attach_pdf_preset_store(&mut self, dir: PathBuf) { + if dir.is_dir() { + self.prefs.pdf_presets = load_presets(&dir); + self.pdf_preset_dir = Some(dir); + } else { + self.pdf_preset_dir = Some(dir); + for preset in self.prefs.pdf_presets.clone() { + self.store_pdf_preset(None, &preset); + } + } + } + + /// Write the saved preset `preset` to the store, which held it as `old_name` when that is set + /// (a rename). Without a store, nothing happens. + pub(crate) fn store_pdf_preset(&self, old_name: Option<&str>, preset: &PdfPreset) { + let Some(dir) = &self.pdf_preset_dir else { return }; + let mut clean = preset.clone(); + clean.settings.clear_passwords(); + let file = format!("{}.{PRESET_FORMAT}", file_stem(&clean.name)); + if let Some(old) = old_name.map(|o| format!("{}.{PRESET_FORMAT}", file_stem(o))) + && old != file + { + take(dir, &old); + } + match serde_json::to_vec_pretty(&PresetFile::new(vec![clean])) { + Ok(bytes) => { + put(dir, &file, &bytes); + } + Err(e) => log::warn!("can't save the PDF preset `{}`: {e}", preset.name), + } + } + + /// Remove the preset `name` from the store. + pub(crate) fn unstore_pdf_preset(&self, name: &str) { + if let Some(dir) = &self.pdf_preset_dir { + take(dir, &format!("{}.{PRESET_FORMAT}", file_stem(name))); + } + } + + /// Keep the ICC profiles this session loads in `dir`, and register the ones already there. + /// Call it before anything that names a profile (restoring the preferences, the first command). + pub fn attach_profile_store(&mut self, dir: PathBuf) { + for path in list_files(&dir, PROFILE_EXT) { + let loaded = + read_capped(&path, MAX_PROFILE_FILE).and_then(|bytes| vectorcraft_color::cms::register_icc(&bytes, None).map_err(|e| e.to_string())); + if let Err(e) = loaded { + log::warn!("skipping the stored colour profile {}: {e}", path.display()); + } + } + self.profile_dir = Some(dir); + } + + /// Write the profile `name` (its file's `bytes`) to the store; `false` when it didn't go there. + pub(crate) fn store_profile(&self, name: &str, bytes: &[u8]) -> bool { + self.profile_dir.as_deref().is_some_and(|dir| put(dir, &format!("{}.{PROFILE_EXT}", file_stem(name)), bytes)) + } + + /// Remove the profile `name` from the store. + pub(crate) fn unstore_profile(&self, name: &str) { + if let Some(dir) = &self.profile_dir { + take(dir, &format!("{}.{PROFILE_EXT}", file_stem(name))); + } + } +} + +/// The presets of the files in `dir`: valid ones only, no name twice (any case), none called like a +/// built-in preset, at most [`MAX_STORE_FILES`]. +fn load_presets(dir: &Path) -> Vec { + let mut presets: Vec = vec![]; + for path in list_files(dir, PRESET_FORMAT) { + let file = read_capped(&path, MAX_PRESET_FILE).and_then(|bytes| serde_json::from_slice::(&bytes).map_err(|e| e.to_string())); + let file = match file { + Ok(f) if f.format == PRESET_FORMAT => f, + Ok(f) => { + log::warn!("skipping {}: not a PDF presets file (format `{}`)", path.display(), f.format); + continue; + } + Err(e) => { + log::warn!("skipping the stored PDF preset {}: {e}", path.display()); + continue; + } + }; + for mut preset in file.presets { + let name = preset.name.trim().to_string(); + let usable = !name.is_empty() + && builtin_preset(&name).is_none() + && !presets.iter().any(|q| q.name.eq_ignore_ascii_case(&name)) + && preset.settings.check_values().is_ok() + && presets.len() < MAX_STORE_FILES; + if usable { + preset.name = name; + preset.settings.clear_passwords(); + presets.push(preset); + } else { + log::warn!("skipping the PDF preset `{name}` of {}", path.display()); + } + } + } + presets +} diff --git a/crates/engine/src/tests_store.rs b/crates/engine/src/tests_store.rs new file mode 100644 index 000000000..f630d78eb --- /dev/null +++ b/crates/engine/src/tests_store.rs @@ -0,0 +1,128 @@ +//! The stores of PDF presets and ICC profiles (#2168, #2174): what one session saves, the next one +//! finds; damaged or oversized files are skipped, never fatal. + +use serde_json::{Value, json}; +use vectorcraft_color::cms; + +use crate::Session; +use crate::store::{MAX_PRESET_FILE, file_stem}; + +fn fresh_dir(tag: &str) -> std::path::PathBuf { + let dir = vectorcraft_testkit::temp_dir(&format!("store-{tag}")); + let _ = std::fs::remove_dir_all(&dir); + dir +} + +fn saved_names(s: &mut Session) -> Vec { + let v = s.execute("pdf.preset.list", &json!({})).unwrap(); + v["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == false).map(|p| p["name"].as_str().unwrap().to_string()).collect() +} + +#[test] +fn saved_presets_come_back_in_the_next_session_and_renames_and_deletes_follow() { + let dir = fresh_dir("presets").join("PDF Presets"); + let mut a = Session::new(); + a.attach_pdf_preset_store(dir.clone()); + a.execute("pdf.preset.save", &json!({"name": "Press", "description": "for the printer", "preset": "Press Quality"})).unwrap(); + a.execute("pdf.preset.save", &json!({"name": "Web"})).unwrap(); + a.execute("pdf.preset.save", &json!({"name": "Web", "newName": "Screen"})).unwrap(); + a.execute("pdf.preset.save", &json!({"name": "Old"})).unwrap(); + a.execute("pdf.preset.delete", &json!({"name": "Old"})).unwrap(); + + let mut b = Session::new(); + b.attach_pdf_preset_store(dir.clone()); + assert_eq!(saved_names(&mut b), ["Press", "Screen"]); + let press = b.prefs.pdf_presets.iter().find(|p| p.name == "Press").unwrap(); + assert_eq!(press.description, "for the printer"); + assert_eq!(press.settings, b.pdf_presets().into_iter().find(|p| p.name == "Press Quality").unwrap().settings); + // The file of a name that is gone is gone. + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2); + assert!(!dir.join(format!("{}.vcpdfpresets", file_stem("Old"))).exists()); +} + +#[test] +fn a_folder_that_does_not_exist_yet_takes_the_presets_the_preferences_held() { + let dir = fresh_dir("seed").join("PDF Presets"); + let mut a = Session::new(); + a.execute("pdf.preset.save", &json!({"name": "From Preferences"})).unwrap(); + a.attach_pdf_preset_store(dir.clone()); + assert!(dir.is_dir(), "written at once"); + let mut b = Session::new(); + b.attach_pdf_preset_store(dir); + assert_eq!(saved_names(&mut b), ["From Preferences"]); +} + +#[test] +fn damaged_unsafe_and_oversized_preset_files_are_skipped() { + let dir = fresh_dir("damaged").join("PDF Presets"); + let mut a = Session::new(); + a.attach_pdf_preset_store(dir.clone()); + a.execute("pdf.preset.save", &json!({"name": "Good"})).unwrap(); + let write = |name: &str, bytes: &[u8]| std::fs::write(dir.join(name), bytes).unwrap(); + write("a-not-json.vcpdfpresets", b"{ not json"); + write("b-other-format.vcpdfpresets", br#"{"format": "something else", "presets": []}"#); + let huge = vec![b' '; MAX_PRESET_FILE as usize + 1]; + write("c-huge.vcpdfpresets", &huge); + write("d-readme.txt", b"ignored"); + let good = std::fs::read_to_string(dir.join(format!("{}.vcpdfpresets", file_stem("Good")))).unwrap(); + // A built-in name, an empty name, a second "good" in another case, and values no export accepts. + let mut file: Value = serde_json::from_str(&good).unwrap(); + let mut preset = file["presets"][0].clone(); + for name in ["High Quality Print", " ", "GOOD"] { + preset["name"] = json!(name); + file["presets"].as_array_mut().unwrap().push(preset.clone()); + } + preset["name"] = json!("Bad Values"); + preset["settings"]["advanced"]["fontSubsetPercent"] = json!(500); + file["presets"].as_array_mut().unwrap().push(preset); + write("e-mixed.vcpdfpresets", file.to_string().as_bytes()); + + let mut b = Session::new(); + b.attach_pdf_preset_store(dir); + assert_eq!(saved_names(&mut b), ["Good"]); +} + +/// An ICC file of an RGB profile named `name` (a built-in one with its description replaced by a +/// name of the same length). +fn icc_named(name: &str) -> Vec { + let builtin = cms::DISPLAY_P3; + assert_eq!(builtin.len(), name.len()); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + let mut bytes = cms::icc_bytes(builtin).unwrap().to_vec(); + let (from, to) = (utf16(builtin), utf16(name)); + let at = bytes.windows(from.len()).position(|w| w == from).unwrap(); + bytes[at..at + to.len()].copy_from_slice(&to); + bytes +} + +#[test] +fn stored_profiles_register_again_and_removing_one_removes_its_copy() { + let dir = fresh_dir("profiles").join("Color Profiles"); + std::fs::create_dir_all(&dir).unwrap(); + let name = "StoreRGB-1"; + let file = format!("{}.icc", file_stem(name)); + std::fs::write(dir.join("junk.icc"), b"not a profile").unwrap(); + std::fs::write(dir.join("empty.icc"), b"").unwrap(); + std::fs::write(dir.join(&file), icc_named(name)).unwrap(); + assert!(cms::profile(name).is_none()); + + let mut s = Session::new(); + s.attach_profile_store(dir.clone()); + assert!(cms::profile(name).is_some_and(|p| !p.builtin), "registered from the store"); + + // A profile loaded from elsewhere is copied in, whatever the file is called. + let other = "StoreRGB-2"; + let loose = vectorcraft_testkit::temp_dir("store-loose").join("whatever.icm"); + std::fs::write(&loose, icc_named(other)).unwrap(); + let r = s.execute("color.loadProfile", &json!({"path": loose.to_str().unwrap()})).unwrap(); + assert_eq!((r["name"].as_str(), r["remembered"].as_bool()), (Some(other), Some(true))); + std::fs::remove_file(&loose).unwrap(); + assert_eq!(std::fs::read(dir.join(format!("{}.icc", file_stem(other)))).unwrap(), icc_named(other)); + + let r = s.execute("color.removeProfile", &json!({"name": name})).unwrap(); + assert_eq!(r["removed"], name); + assert!(cms::profile(name).is_none() && !dir.join(&file).exists()); + assert!(s.execute("color.removeProfile", &json!({"name": name})).is_err(), "already gone"); + assert!(s.execute("color.removeProfile", &json!({"name": cms::SRGB})).is_err(), "built-in profiles stay"); + s.execute("color.removeProfile", &json!({"name": other})).unwrap(); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 9acbb54a9..e3e8d7a51 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1559,6 +1559,7 @@ Live Paint Selection Tool Инструмент за селекция на динамично оцветяване Live Shape Properties Свойства на динамична фигура Load Color Profile… Зареждане на файлов цвят… + Remove Color Profile… Премахване на цветови профил… Load Image into Clipboard Зареждане на изображение в клипборда Load Metafile into Clipboard Зареждане на метафайл в клипборда Load PDF into Clipboard Зареждане на PDF в клипборда diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d30d0166c..979004bf3 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2864,6 +2864,7 @@ theme Appearance Motiv Convert Document Color Mode Převést barevný režim dokumentu Gamut Warning Upozornění na gamut Load Color Profile… Načíst barevný profil… + Remove Color Profile… Odebrat barevný profil… Plates Desky Spot Color Přímá barva Add to Swatches Přidat do vzorníku diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 92074af90..fdbab8116 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1584,6 +1584,7 @@ Live Paint Selection Tool Interaktiv-malen-Auswahlwerkzeug Live Shape Properties Eigenschaften interaktiver Formen Load Color Profile… Farbprofil laden… + Remove Color Profile… Farbprofil entfernen… Load Image into Clipboard Bild in die Zwischenablage laden Load Metafile into Clipboard Metadatei in die Zwischenablage laden Load PDF into Clipboard PDF in die Zwischenablage laden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 5cc3023d9..608c2749f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2319,6 +2319,7 @@ theme Appearance Εμφάνιση Live Paint Selection Tool Επιλογή δυναμικής βαφής (Live Paint Selection Tool) Live Shape Properties Ιδιότητες δυναμικού σχήματος Load Color Profile… Φόρτωση προφίλ χρώματος… + Remove Color Profile… Αφαίρεση προφίλ χρώματος… Load Image into Clipboard Φόρτωση εικόνας στο πρόχειρο Load Metafile into Clipboard Φόρτωση μετααρχείου στο πρόχειρο Load PDF into Clipboard Φόρτωση PDF στο πρόχειρο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ce0da6fe8..282e5dd11 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1600,6 +1600,7 @@ Live Paint Selection Tool Herramienta Selección de pintura interactiva Live Shape Properties Propiedades de forma dinámica Load Color Profile… Cargar perfil de color… + Remove Color Profile… Quitar perfil de color… Load Image into Clipboard Cargar imagen en el portapapeles Load Metafile into Clipboard Cargar metarchivo en el portapapeles Load PDF into Clipboard Cargar PDF en el portapapeles diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 4e5bb0b35..a0fd4b8c1 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2814,6 +2814,7 @@ theme Appearance Ulkoasu Convert Document Color Mode Muunna asiakirjan väritila Gamut Warning Väriavaruusvaroitus Load Color Profile… Lataa väriprofiili… + Remove Color Profile… Poista väriprofiili… Plates Erottelut Spot Color Spottiväri Add to Swatches Lisää värimalleihin diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6c011a2ce..344b340dc 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1599,6 +1599,7 @@ Live Paint Selection Tool Outil Sélection de peinture dynamique Live Shape Properties Propriétés de la forme dynamique Load Color Profile… Charger un profil colorimétrique… + Remove Color Profile… Supprimer un profil colorimétrique… Load Image into Clipboard Charger une image dans le presse-papiers Load Metafile into Clipboard Charger un métafichier dans le presse-papiers Load PDF into Clipboard Charger un PDF dans le presse-papiers diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 27a6eb2b5..cf747808f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2023,6 +2023,7 @@ Live Paint Selection Tool Élőfestés-kijelölés Live Shape Properties Élő alakzat tulajdonságai Load Color Profile… Színprofil betöltése… + Remove Color Profile… Színprofil eltávolítása… Load Image into Clipboard Kép betöltése a vágólapra Load Metafile into Clipboard Metafájl betöltése a vágólapra Load PDF into Clipboard PDF betöltése a vágólapra diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7cffb8995..fe5f1a4dc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1602,6 +1602,7 @@ Live Paint Selection Tool Strumento Selezione pittura dinamica Live Shape Properties Proprietà forma dinamica Load Color Profile… Carica profilo colore… + Remove Color Profile… Rimuovi profilo colore… Load Image into Clipboard Carica immagine negli appunti Load Metafile into Clipboard Carica metafile negli appunti Load PDF into Clipboard Carica PDF negli appunti diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index abe633da1..af96e8078 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1781,6 +1781,7 @@ Live Paint Selection Tool ライブペイント選択ツール Live Shape Properties ライブシェイプのプロパティ Load Color Profile… カラープロファイルを読み込む… + Remove Color Profile… カラープロファイルを削除… Load Image into Clipboard 画像をクリップボードに読み込む Load Metafile into Clipboard メタファイルをクリップボードに読み込む Load PDF into Clipboard PDF をクリップボードに読み込む diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2d36be8b6..4268d7378 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1776,6 +1776,7 @@ Live Paint Selection Tool 라이브 페인트 선택 도구 Live Shape Properties 라이브 모양 속성 Load Color Profile… 색상 프로파일 불러오기… + Remove Color Profile… 색상 프로파일 제거… Load Image into Clipboard 이미지를 클립보드로 불러오기 Load Metafile into Clipboard 메타파일을 클립보드로 불러오기 Load PDF into Clipboard PDF를 클립보드로 불러오기 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 08b10404f..ba1a49344 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2735,6 +2735,7 @@ theme Appearance Wygląd Convert Document Color Mode Konwertuj tryb koloru dokumentu Gamut Warning Ostrzeżenie gamutu Load Color Profile… Wczytaj profil kolorów… + Remove Color Profile… Usuń profil kolorów… Plates Płyty Spot Color Kolor dodatkowy Add to Swatches Dodaj do próbek diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4fe9c2ca5..32bf7d118 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1341,6 +1341,7 @@ Live Paint Selection Tool Ferramenta Seleção de Pintura ao Vivo Live Shape Properties Propriedades de Forma ao Vivo Load Color Profile… Carregar Perfil de Cor… + Remove Color Profile… Remover Perfil de Cor… Load Image into Clipboard Carregar Imagem na Área de Transferência Load Metafile into Clipboard Carregar Metarquivo na Área de Transferência Load PDF into Clipboard Carregar PDF na Área de Transferência diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c3ca047a8..8d1287235 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1600,6 +1600,7 @@ Live Paint Selection Tool Инструмент «Выделение быстрой заливки» Live Shape Properties Свойства динамической фигуры Load Color Profile… Загрузить профиль цвета… + Remove Color Profile… Удалить профиль цвета… Load Image into Clipboard Загрузить изображение в буфер обмена Load Metafile into Clipboard Загрузить метафайл в буфер обмена Load PDF into Clipboard Загрузить PDF в буфер обмена diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 367aeab82..713a977d0 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1559,6 +1559,7 @@ Live Paint Selection Tool Markeringsverktyg för direktmålning Live Shape Properties Egenskaper för direktform Load Color Profile… Läs in färgprofil… + Remove Color Profile… Ta bort färgprofil… Load Image into Clipboard Läs in bild till urklipp Load Metafile into Clipboard Läs in metafil till urklipp Load PDF into Clipboard Läs in PDF till urklipp diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 22c386082..5d64483ec 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1596,6 +1596,7 @@ Live Paint Selection Tool Інструмент «Виділення швидкої заливки» Live Shape Properties Властивості динамічної фігури Load Color Profile… Завантажити профіль кольору… + Remove Color Profile… Видалити профіль кольору… Load Image into Clipboard Завантажити зображення в буфер обміну Load Metafile into Clipboard Завантажити метафайл у буфер обміну Load PDF into Clipboard Завантажити PDF у буфер обміну diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 62232418c..dd552e948 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1833,6 +1833,7 @@ Live Paint Selection Tool Công cụ Chọn Tô trực tiếp Live Shape Properties Thuộc tính hình dạng trực tiếp Load Color Profile… Tải hồ sơ màu… + Remove Color Profile… Xóa hồ sơ màu… Load Image into Clipboard Tải ảnh vào bảng tạm Load Metafile into Clipboard Tải metafile vào bảng tạm Load PDF into Clipboard Tải PDF vào bảng tạm diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3e5782fd2..251768913 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1499,6 +1499,7 @@ Live Paint Selection Tool 实时上色选择工具 Live Shape Properties 实时形状属性 Load Color Profile… 载入颜色配置文件… + Remove Color Profile… 移除颜色配置文件… Load Image into Clipboard 将图像载入剪贴板 Load Metafile into Clipboard 将图元文件载入剪贴板 Load PDF into Clipboard 将 PDF 载入剪贴板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f215ef7bd..23bdde637 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1574,6 +1574,7 @@ Live Paint Selection Tool 即時上色選取工具 Live Shape Properties 即時形狀內容 Load Color Profile… 載入色彩描述檔… + Remove Color Profile… 移除色彩描述檔… Load Image into Clipboard 將影像載入剪貼簿 Load Metafile into Clipboard 將中繼檔載入剪貼簿 Load PDF into Clipboard 將 PDF 載入剪貼簿 diff --git a/docs/development.md b/docs/development.md index 978fa2e4a..7627cbfb2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -85,7 +85,8 @@ By default VectorCraft's own crates log at `info` and everything else at `warn`. |---|---| | `VECTORCRAFT_CONTROL_PORT` | Same as `--control `, which overrides it; a value that isn't a port from 1 to 65535 stops the app | | `VECTORCRAFT_IN_WINDOW_MENUS` | Any value but empty or `0`: same as `--in-window-menus` (see [macOS: the menu bar](#macos-the-menu-bar)) | -| `VECTORCRAFT_NO_PREFS` | No preferences read or written, no default Data Recovery folder and no log file (agents' test runs) | +| `VECTORCRAFT_NO_PREFS` | No preferences read or written, no default Data Recovery folder and no log file, and no saved PDF presets or loaded ICC profiles kept (agents' test runs; also for `vectorcraft-cli`) | +| `VECTORCRAFT_CONFIG_DIR` | The folder for the preferences and the folders next to them (`PDF Presets`, `Color Profiles`, …), for the app and `vectorcraft-cli` (see `docs/control-protocol.md`) | | `WGPU_POWER_PREF` | Graphics adapter: `low`, `high` or `none` (see [Desktop graphics processor](#desktop-graphics-processor)) | | `WGPU_ADAPTER_NAME` | Graphics adapter by (part of) its name, any case (see [Desktop graphics processor](#desktop-graphics-processor)) | | `WGPU_BACKEND` | Graphics backends: `vulkan`, `dx12`, `metal`, `gl` | diff --git a/docs/mcp.md b/docs/mcp.md index e0f2fb9e5..a1b97ef2d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -771,6 +771,27 @@ applies to the `--cmd` just before it. `run` also accepts the host commands `fil `file.exportForScreens` and `tool.select`. `run --in`, `convert` and `info` read every format `document.open` reads (`vectorcraft-cli --help` lists them). +### What a headless session remembers + +`vectorcraft-cli run` and the headless `mcp` server keep two things between runs, in folders next to the +preferences (the folder the desktop app uses, so both see the same ones; `VECTORCRAFT_CONFIG_DIR` moves it): + +- **Saved PDF presets** (`pdf.preset.save`, `.delete`, `.import`): one `.vcpdfpresets` file per preset in + `PDF Presets`, written as the command runs. Presets saved earlier are there at the next start and work as + `preset` in the PDF options. +- **ICC profiles** (`color.loadProfile`): a copy of the file goes into `Color Profiles` (the result says + `remembered: true`), and every profile there is registered again at the next start, before the first + command, so `edit.colorSettings {"cmyk": "…"}` or a preset's output intent can name it in a later run. The + copy is what counts: the original may move or be deleted. `color.removeProfile {name}` forgets a profile and its + copy (not while it is a working space). + +Not remembered: Color Settings, the document, anything else. Nothing is read or written when +`VECTORCRAFT_NO_PREFS` is set, and a server confined with `--automation-read-root` or +`--automation-write-root` keeps nothing either (the folders are outside its roots). A file that is damaged, too large +(a profile over 32 MB, a presets file over 1 MB) or not a valid preset or profile is skipped and logged. When the +desktop app starts, the presets it kept in its preferences file before this folder existed are moved into it; after +that the folder is the one place they live, so a preset deleted by `vectorcraft-cli` is gone in the app too. + ### Parameter keys `command_run`, each step of `command_batch`, `run --cmd … --params` and the control channel's `engine.execute` check @@ -2476,8 +2497,10 @@ newName?, description?, preset?, …document.exportPdf options}` creates or chan the preset's own settings; `newName` renames it; built-in presets are read-only; passwords are never stored); `pdf.preset.delete {name}` deletes one. `pdf.preset.export {names?, path?}` writes them as a `.vcpdfpresets` JSON file (without `path` it returns `data`), and `pdf.preset.import {path? | data? | dataBase64?, replace?}` adds a file's -presets (names in use get a number unless `replace`). Saved presets live with the preferences and work as `preset` +presets (names in use get a number unless `replace`). Saved presets work as `preset` in `document.exportPdf`, `document.pdfSettings`, `document.export`/`serialize` with format `pdf` and `.ai` saves. +They are kept across restarts, in the desktop app and in `vectorcraft-cli` alike (see +[What a headless session remembers](#what-a-headless-session-remembers)). ```json {"name":"run_command","arguments":{"command":"pdf.preset.save","params":{"name":"Web","preset":"Smallest File Size","compatibility":"1.5"}}} From d55b4f77cfb38f8ef979f05d317d0a9c7b43fc13 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:49:24 +0700 Subject: [PATCH 070/208] AI import: cite the published format specification by its title --- crates/eps/src/import/ai/read.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 95cad73c7..45808b2cb 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -1131,8 +1131,8 @@ impl<'a> Reader<'a> { } "d" => self.dash(&vals), "D" => { - // The published format §6.8 permits `u` groups of compound members: - // https://www.moon-soft.com/program/FORMAT/graphics/ai30.pdf + // The published Adobe Illustrator File Format Specification (version 3.0), §6.8, + // permits `u` groups of compound members. // Their explicit winding declarations describe subpaths, not separate paints; // modern streams can also end these groups with `9 () XW`. let n = self.frames.len(); From 1eb9bea82aab4e0d3b2fee03b86fb80e039d3721 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 22:27:31 +0200 Subject: [PATCH 071/208] Inspect: scope document.inspect / doc_inspect by ids, kind, limit and offset (#2167) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 22 +++++- crates/engine/src/inspect.rs | 106 ++++++++++++++++++++++++++- crates/engine/src/tests.rs | 59 +++++++++++++++ crates/engine/tests/command_sweep.rs | 2 + crates/mcp/src/tests.rs | 7 ++ crates/mcp/src/tools.rs | 30 +++++--- docs/control-protocol.md | 2 +- docs/mcp.md | 13 +++- 8 files changed, 223 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2c0eff9f4..7555d7809 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { ), cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close), cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate), - cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount)", has_doc, |s, p| Ok(inspect::document_opts(s, slice_opts(p, "document.inspect")?))), + cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect), cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node), cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, limit?: max matches (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total}", has_doc, doc_find), cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))), @@ -172,6 +172,26 @@ fn doc_node(s: &mut Session, p: &Value) -> Result { if summary { Ok(inspect::node_summary_opts(n, opts, &doc.symbol_boxes())) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) } } +/// Most node ids `document.inspect {ids}` takes. +const MAX_INSPECT_IDS: usize = 10_000; + +fn doc_inspect(s: &mut Session, p: &Value) -> Result { + const C: &str = "document.inspect"; + let opts = slice_opts(p, C)?; + let ids = match p.get("ids") { + None | Some(Value::Null) => None, + Some(Value::Array(a)) if a.len() > MAX_INSPECT_IDS => return Err(bad(C, format!("`ids` takes at most {MAX_INSPECT_IDS} ids"))), + Some(_) => Some(checked_ids_param(s, p, "ids", C)?), + }; + let kind = match p.get("kind") { + None | Some(Value::Null) => None, + Some(Value::String(k)) => Some(k.clone()), + Some(_) => return Err(bad(C, "`kind` must be a string")), + }; + let scope = inspect::Scope { ids, kind, limit: opt_u64(p, C, "limit")?, offset: opt_u64(p, C, "offset")?.unwrap_or(0) }; + Ok(inspect::document_scoped(s, opts, &scope)) +} + /// `depth` / `childLimit` for a summary read: absent (or null) means no limit. fn slice_opts(p: &Value, cmd: &str) -> Result { Ok(inspect::SummaryOpts { depth: opt_u64(p, cmd, "depth")?, child_limit: opt_u64(p, cmd, "childLimit")?, compound_member: false }) diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index c5fe27670..1850e5922 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -131,9 +131,113 @@ pub fn document(s: &Session) -> Value { /// The same summary with [`SummaryOpts`] applied to the layer tree (artboards and /// the rest always come whole): `depth: 0` is the skeleton — top layers with counts. pub fn document_opts(s: &Session, opts: SummaryOpts) -> Value { + document_scoped(s, opts, &Scope::default()) +} + +/// Which nodes `document.inspect` lists in place of the whole layer tree. +#[derive(Clone, Debug, Default)] +pub struct Scope { + /// Only these nodes (any level, already checked to exist), in the order given. + pub ids: Option>, + /// Only nodes of this panel label (case-insensitive), searched in the subtrees of `ids` + /// or else of every layer. + pub kind: Option, + /// At most this many entries after `offset` (`None`: all). + pub limit: Option, + /// Entries skipped first. + pub offset: u64, +} + +impl Scope { + fn scoped(&self) -> bool { + self.ids.is_some() || self.kind.is_some() + } +} + +/// A node with the ids of its ancestors (layer first). +type Hit<'a> = (&'a Node, Vec); + +fn ancestors(d: &Document, id: vectorcraft_doc::NodeId) -> Vec { + let mut out = Vec::new(); + let mut cur = d.parent_of(id); + // The bound keeps a malformed tree from looping. + while let Some(p) = cur.filter(|_| out.len() < 10_000) { + out.push(p.0); + cur = d.parent_of(p); + } + out.reverse(); + out +} + +fn collect_kind<'a>(n: &'a Node, kind: &str, path: &mut Vec, out: &mut Vec>) { + if n.kind_label().eq_ignore_ascii_case(kind) { + out.push((n, path.clone())); + } + if let Some(ch) = n.children() { + path.push(n.id.0); + for c in ch.iter().rev() { + collect_kind(c, kind, path, out); + } + path.pop(); + } +} + +/// [`document_opts`] with the layer tree replaced by the nodes `scope` picks. Without `ids` +/// or `kind` they are the top layers (`layers`, paged by `limit`/`offset`); with either they +/// are `nodes` (each with its `path` of ancestor ids when picked by `kind`). A paged or scoped +/// reply also reports `total`, the entries before paging. +pub fn document_scoped(s: &Session, opts: SummaryOpts, scope: &Scope) -> Value { let Some(st) = s.active() else { return Value::Null }; let d = &st.doc; let symbols = d.symbol_boxes(); + let mut hits: Vec = Vec::new(); + match (&scope.ids, scope.kind.as_deref().filter(|k| !k.is_empty())) { + (None, None) => hits.extend(d.layers.iter().rev().map(|l| (&**l, Vec::new()))), + (Some(ids), None) => hits.extend(ids.iter().filter_map(|&i| d.node(i)).map(|n| (n, Vec::new()))), + (ids, Some(kind)) => { + let mut path = Vec::new(); + match ids { + Some(ids) => { + for n in ids.iter().filter_map(|&i| d.node(i)) { + path = ancestors(d, n.id); + collect_kind(n, kind, &mut path, &mut hits); + } + } + None => d.layers.iter().rev().for_each(|l| collect_kind(l, kind, &mut path, &mut hits)), + } + } + } + let total = hits.len(); + let skip = usize::try_from(scope.offset).unwrap_or(usize::MAX); + let take = scope.limit.map_or(usize::MAX, |l| usize::try_from(l).unwrap_or(usize::MAX)); + let by_kind = scope.kind.as_deref().is_some_and(|k| !k.is_empty()); + let nodes: Vec = hits + .into_iter() + .skip(skip) + .take(take) + .map(|(n, path)| { + let member = d.parent_of(n.id).and_then(|p| d.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. })); + let mut v = node_summary_opts(n, SummaryOpts { compound_member: member, ..opts }, &symbols); + if by_kind { + v["path"] = json!(path); + } + v + }) + .collect(); + let mut out = document_base(s); + if let Some(o) = out.as_object_mut() { + o.insert(if scope.scoped() { "nodes" } else { "layers" }.into(), Value::Array(nodes)); + if scope.scoped() || scope.limit.is_some() || scope.offset > 0 { + o.insert("total".into(), json!(total)); + } + } + out +} + +/// Everything `document.inspect` reports except the layer tree. +fn document_base(s: &Session) -> Value { + let Some(st) = s.active() else { return Value::Null }; + let d = &st.doc; json!({ "title": st.title(), "path": st.path, @@ -142,8 +246,6 @@ pub fn document_opts(s: &Session, opts: SummaryOpts) -> Value { "units": d.units.label(), "colorMode": format!("{:?}", d.color_mode), "artboards": d.artboards.iter().map(|a| json!({"name": a.name, "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height()})).collect::>(), - // Top of the stack first, like the Layers panel. - "layers": d.layers.iter().rev().map(|l| node_summary_opts(l, opts, &symbols)).collect::>(), "currentLayer": st.active_layer.map(|l| l.0), // The rows highlighted in the Layers panel (`layer.setCurrent`, `layer.highlight`). "layerRows": st.highlighted_rows().iter().map(|i| i.0).collect::>(), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index cb2ff2147..bb61e78de 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2042,6 +2042,65 @@ fn document_inspect_slices_the_layer_tree() { assert!(s.execute("document.inspect", &json!({"depth": "deep"})).is_err()); } +/// `document.inspect` scoped by `ids`, `kind`, `limit` and `offset`: each entry is the node as +/// the full tree lists it, bad input is an error, and the unscoped reply keeps its shape. +#[test] +fn document_inspect_scopes_by_ids_kind_and_page() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0).0; + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0).0; + s.execute("select.all", &json!({})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + let c = rect(&mut s, 40.0, 0.0, 10.0, 10.0).0; + let mut ins = |p: Value| s.execute("document.inspect", &p).unwrap(); + + let full = ins(json!({})); + assert!(full.get("nodes").is_none() && full.get("total").is_none(), "{full}"); + let layer = full["layers"][0].clone(); + let layer_id = layer["id"].as_u64().unwrap(); + let find = |tree: &Value, id: u64| -> Value { + fn walk(n: &Value, id: u64) -> Option { + if n["id"] == id { + return Some(n.clone()); + } + n["children"].as_array()?.iter().find_map(|c| walk(c, id)) + } + walk(tree, id).unwrap() + }; + + // ids: just those nodes, in the order given, equal to their part of the full tree. + let some = ins(json!({"ids": [c, group]})); + assert!(some.get("layers").is_none()); + assert_eq!(some["nodes"], json!([find(&layer, c), find(&layer, group)])); + assert_eq!(some["total"], 2); + assert_eq!(some["artboards"], full["artboards"]); + assert_eq!(ins(json!({"ids": [group], "depth": 0}))["nodes"][0].get("children"), None); + + // kind: every match with its ancestor path, top of the stack first; paged. + let paths = ins(json!({"kind": "rectangle"})); + let ids: Vec = paths["nodes"].as_array().unwrap().iter().map(|n| n["id"].as_u64().unwrap()).collect(); + assert_eq!(ids, [c, b, a]); + assert_eq!(paths["nodes"][1]["path"], json!([layer_id, group])); + assert_eq!(paths["total"], 3); + let page = ins(json!({"kind": "Rectangle", "offset": 1, "limit": 1})); + assert_eq!(page["nodes"].as_array().map(Vec::len), Some(1)); + assert_eq!(page["nodes"][0]["id"], b); + assert_eq!(page["total"], 3); + assert_eq!(ins(json!({"kind": "Rectangle", "offset": 99}))["nodes"], json!([])); + assert_eq!(ins(json!({"kind": "Rectangle", "ids": [group]}))["total"], 2); + + // limit/offset alone page the top layers. + let paged = ins(json!({"limit": 0})); + assert_eq!(paged["layers"], json!([])); + assert_eq!(paged["total"], 1); + + for bad in + [json!({"ids": [999_999]}), json!({"ids": "all"}), json!({"ids": [-1]}), json!({"kind": 3}), json!({"limit": -1}), json!({"offset": 1.5})] + { + assert!(s.execute("document.inspect", &bad).is_err(), "{bad}"); + } +} + /// `document.find` searches names, kinds and type content across the whole tree and /// answers ids with ancestor paths plus the total, so capped replies stay explicit. #[test] diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 0bf31cecb..1a24e5a25 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -275,6 +275,8 @@ fn structured_junk() { ("stroke.set", json!({"weight": -5, "dash": [0, 0, 0], "miterLimit": -1})), ("stroke.set", json!({"weight": 1e308, "dash": [-1, -2], "dashOffset": 1e308})), ("stroke.set", json!({"dash": [1e-12]})), + ("document.inspect", json!({"ids": [u64::MAX, 0, 1e308], "kind": "", "limit": u64::MAX, "offset": u64::MAX})), + ("document.inspect", json!({"ids": null, "kind": {"a": 1}, "limit": -1, "offset": "x", "depth": u64::MAX})), ("transparency.set", json!({"opacity": -50})), ("transparency.set", json!({"opacity": 1e308, "blend": "NoSuchBlend"})), ("perspective.move", json!({"from": [0, 0], "to": [1e308, -1e308], "perpendicular": true, "copy": true, "plane": "ground"})), diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 13c9e964b..6007ff914 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -240,6 +240,13 @@ fn headless_end_to_end() { assert_eq!(skel["artboards"], doc["artboards"]); assert_eq!(call(&mut s, 42, "inspect_document", json!({"depth": -1}))["isError"], true); + // Scoping reaches the command: one node by id equals its part of the full tree; + // an unknown id is a tool error. + let r = call(&mut s, 43, "doc_inspect", json!({"ids": [id]})); + let one: Value = serde_json::from_str(&text_of(&r)).unwrap(); + assert_eq!(one["nodes"], json!([rect]), "{one}"); + assert_eq!(call(&mut s, 44, "doc_inspect", json!({"ids": [999_999]}))["isError"], true); + // Screenshot returns image content (base64 PNG) and text. let shot_path = tmp("shot.png"); let r = call(&mut s, 5, "screenshot", json!({"path": shot_path.to_str().unwrap()})); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index d4f81d6a1..523693f28 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -36,6 +36,20 @@ fn num(desc: &str) -> Value { fn string(desc: &str) -> Value { json!({"type": "string", "description": desc}) } +/// The arguments `doc_inspect` / `inspect_document` pass on to `document.inspect`. +const INSPECT_KEYS: [&str; 6] = ["depth", "childLimit", "ids", "kind", "limit", "offset"]; + +/// Their schema. +fn inspect_props() -> Value { + json!({ + "depth": {"type": "integer", "minimum": 0, "description": "Child levels of the layer tree to include (default all; 0 = top layers with counts)"}, + "childLimit": {"type": "integer", "minimum": 0, "description": "Children shown per node (default all; a level that shows fewer reports childCount)"}, + "ids": {"type": "array", "maxItems": 10000, "items": {"type": "integer", "minimum": 0}, "description": "List just these nodes (any level) as `nodes` instead of the whole layer tree; an unknown id is an error"}, + "kind": {"type": "string", "description": "List every node of this panel label (Layer, Group, Path, Type, Image, ...; ignores case) as `nodes`, each with its ancestor `path`; searches the subtrees of `ids` when given"}, + "limit": {"type": "integer", "minimum": 0, "description": "At most this many layers or nodes (the reply adds `total`)"}, + "offset": {"type": "integer", "minimum": 0, "description": "Layers or nodes skipped first (default 0)"} + }) +} fn paint_schema(what: &str) -> Value { json!({ "description": format!("{what}: \"#rrggbb\", \"none\", [r,g,b] (0..1), {{\"c\",\"m\",\"y\",\"k\"}}, {{\"gray\"}}, or a full paint.setFill params object ({{\"gradient\":…}} / {{\"swatch\":name}})"), @@ -129,8 +143,8 @@ pub fn tool_definitions() -> Vec { tool( "doc_inspect", "Inspect document", - "Summary of the active document, including artboards, layer tree, selection and history.", - obj(json!({"depth":{"type":"integer", "minimum":0},"childLimit":{"type":"integer", "minimum":0}}), &[]), + "Summary of the active document, including artboards, layer tree, selection and history. On a large document scope it: depth 0 for the layer skeleton, ids for just those nodes, kind for every node of one kind, limit/offset to page.", + obj(inspect_props(), &[]), true, ), tool( @@ -175,13 +189,7 @@ pub fn tool_definitions() -> Vec { "inspect_document", "Inspect document", "Summary of the active document: artboards, layer tree (ids, names, kinds, bounds, fill/stroke), selection, undo history, current tool and default paint. On a large document pass depth: 0 for the layer skeleton (truncated levels report childCount), then locate objects with run_command document.find and read them with document.node.", - obj( - json!({ - "depth": {"type": "integer", "minimum": 0, "description": "Child levels of the layer tree to include (default all; 0 = top layers with counts)"}, - "childLimit": {"type": "integer", "minimum": 0, "description": "Children shown per node (default all; a level that shows fewer reports childCount)"} - }), - &[], - ), + obj(inspect_props(), &[]), true, ), tool( @@ -919,9 +927,9 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result { - // Only the slicing options reach the command, which validates them. + // Only the slicing and scope options reach the command, which validates them. let slice: Map = - a.iter().filter(|(k, _)| matches!(k.as_str(), "depth" | "childLimit")).map(|(k, v)| (k.clone(), v.clone())).collect(); + a.iter().filter(|(k, _)| INSPECT_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect(); j(b.call("document.inspect", Value::Object(slice))?) } "ui_inspect" | "inspect_ui" => { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d8ce4596c..461d4898b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -90,7 +90,7 @@ the two can still win that race. |---|---|---| | `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`). A key of `params` that the command's params description doesn't name is an error naming it and the nearest key the command takes, before anything runs (`unknown parameter `widht`; did you mean `width`?`); see [Parameter keys](mcp.md#parameter-keys) | | `engine.commands` | | every command with label, shortcut, params doc, enablement | -| `document.inspect` | `{depth?, childLimit?}` | layer tree, selection, the key object (`key`, null without one), history, paint defaults; the options slice the layer tree as `document.node {summary: true}` does. Each node reports `opacity` below 1 and `blend` when it isn't Normal; objects and compound paths report `fill`, `stroke` and `strokeWidth`, a compound path's members `compoundMember: true` instead | +| `document.inspect` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | layer tree, selection, the key object (`key`, null without one), history, paint defaults; `depth` / `childLimit` slice the layer tree as `document.node {summary: true}` does; `ids`, `kind`, `limit` and `offset` list just some nodes as `nodes` (with `total`) instead of the whole tree, see [mcp.md](mcp.md). Each node reports `opacity` below 1 and `blend` when it isn't Normal; objects and compound paths report `fill`, `stroke` and `strokeWidth`, a compound path's members `compoundMember: true` instead | | `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way), `fileDialog` (`"open"`, `"place"`, `"save"` or `"folder"` while a native file dialog is open, `null` otherwise; see below) | | `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item | | `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected. While a menu opened over locked objects is open, it has an `Unlock` submenu with one item for each locked object under the pointer, topmost first and at most 20, labeled with the object's name, which runs `layer.setProps {ids: [id], locked: false}`. A locked group gets one item, and the locked objects inside it get none. Hidden objects, objects on hidden, locked or template layers, and in isolation mode objects outside the isolated group get no item | diff --git a/docs/mcp.md b/docs/mcp.md index 5f95424d0..5d8f0c204 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -189,7 +189,14 @@ node (layers and groups too) reports `opacity` when it is below 1 and `blend` wh On a large document, drill instead of dumping: `inspect_document {depth: 0}` (or `run_command document.inspect {depth?, childLimit?}`, same rules) is the skeleton (artboards with top layers and counts), `run_command document.find {name?, kind?, text?, limit?}` locates nodes across the whole tree, and `document.node` reads the -interesting ones sliced. `document.find` needs at least one filter and all given filters must match; matching +interesting ones sliced. `document.inspect` and the `doc_inspect` / `inspect_document` tools also scope the +reply themselves: `ids: [id…]` lists just those nodes (any level, in the order given, an unknown id is an error, +at most 10000), `kind` lists every node of one panel label (ignoring case; within the subtrees of `ids` when both +are given) with its ancestor `path`, and `limit` / `offset` page the list. A scoped reply carries the nodes as +`nodes` instead of `layers` (each the summary the full tree gives it, sliced by `depth` / `childLimit`) plus +`total`, the entries before paging; artboards, selection and history stay whole. `limit` / `offset` alone page the +top layers (`layers` and `total`). Without these options the reply is the whole layer tree as before. +`document.find` needs at least one filter and all given filters must match; matching ignores case, `name` is a substring of the Layers panel name, `text` a substring of type content and `kind` the exact panel label (`Layer`, `Group`, `Clip Group`, `Path`, `Compound Path`, `Type`, `Image`, `Rectangle`, ...). It answers `{matches: [{id, name, kind, path}], total}` top of the stack first, where `path` is the ancestor @@ -261,7 +268,7 @@ The older names below remain listed because existing workflows use them. | `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text); returns the command array | | `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)) | | `command_batch` | `steps: [{id, params?}]` (at most 256), optional `stop_on_error` (default true), `atomic` and `label`; returns `completed`, `failed`, `results: [{ok, result\|error}]` and `created` (the ids the `draw_path` / `draw_shape` steps made, in order). A step's `id` is a command id, or `draw_path` / `draw_shape` with that tool's arguments as `params` (checked as `tools/call` checks them; the step's result is the tool's, so `"$N.id"` is the new object). Each edit has its own undo step, or with `atomic: true` the batch's edits are one undo step named `label` (else after its first edit; `edit.undoGroup.begin`/`end` underneath); failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below). Kept step results total at most 8 MiB; past that the batch stops with the reason. This is the MCP tool, not the engine command `command.batch` (that one takes `commands: [{command, params}]` and is what the Actions panel plays); use `command_run` with `command.batch` when replaying a recorded action | -| `doc_inspect` | Optional `depth`, `childLimit`; same summary as `inspect_document` | +| `doc_inspect` | Optional `depth`, `childLimit`, `ids`, `kind`, `limit`, `offset`; same summary as `inspect_document` | | `render_preview` | Optional `max_side` (1–4096, default 1024) and `artboard` (0-based, default 0); inline PNG of that artboard without editing it. Artboards too large to render within the allocation bound return a tool error | | `ui_inspect`, `ui_screenshot` | Connected desktop state/window capture; tool errors in headless mode | @@ -302,7 +309,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr |---|---|---| | `list_commands` | `{filter?, query?, menu?, enabledOnly?, brief?}` | The command catalogue: id, label, menu, shortcut, params doc (left out with `brief`), enablement. | | `run_command` | `{command, params?}` | Runs any command. Use it for everything without a dedicated tool. | -| `inspect_document` | `{depth?, childLimit?}` | Artboards, layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. | +| `inspect_document` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | Artboards, layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. | | `inspect_ui` | `{}` | UI state. Remote mode only. | | `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … | | `pointer_gesture` | `{events:[{kind,x,y,mods?}], tool?, mods?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. Events go through the same path as the mouse. | From 6920adf0924de4eafb4c3031e4495cb06b3df339 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 21:30:28 +0200 Subject: [PATCH 072/208] PDF export: report RGB colours and images in a CMYK document (#2162) A CMYK document holding RGB colours or images separates them into CMYK on export, and dark ones come out as rich blacks. The export report now says how many there are. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/export.rs | 64 +++++++++++++++++++++++++++++-- crates/pdf/src/tests_cmykblend.rs | 27 +++++++++++++ docs/gaps.md | 1 + 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index d1901a7d4..1da3b3710 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1,7 +1,7 @@ //! Document → PDF (krilla). Mirrors the tree walk of `vectorcraft-render`. use std::borrow::Cow; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use craft_pdf::write::backend as krilla; @@ -147,6 +147,42 @@ fn user_unit(w: f64, h: f64) -> f64 { if side.is_finite() && side > MAX_PAGE_SIDE { (side / MAX_PAGE_SIDE * 1000.0).ceil() / 1000.0 } else { 1.0 } } +/// The RGB colours and non-CMYK images a CMYK document carries, which the export separates into +/// CMYK. Their darkest values come out as rich blacks, so the export says how many there are. +#[derive(Default)] +pub(crate) struct RgbInCmyk { + /// Distinct RGB colours, by the bits of their channels. + colours: HashSet<[u32; 3]>, + /// Keys of the raster images that aren't CMYK. + images: HashSet, +} + +impl RgbInCmyk { + fn merge(&mut self, other: RgbInCmyk) { + self.colours.extend(other.colours); + self.images.extend(other.images); + } + + /// The warning for the export report; `None` when the document has nothing of the sort. + fn warning(&self) -> Option { + let (c, i) = (self.colours.len(), self.images.len()); + if c == 0 && i == 0 { + return None; + } + let mut parts = vec![]; + if c > 0 { + parts.push(format!("{c} RGB colour{}", if c == 1 { "" } else { "s" })); + } + if i > 0 { + parts.push(format!("{i} RGB or grey image{}", if i == 1 { "" } else { "s" })); + } + Some(format!( + "this CMYK document has {}: they are converted to CMYK through the colour settings, so dark ones can come out as rich blacks on several plates; paint them with CMYK colours (Edit › Edit Colors › Convert to CMYK) for pure black ink", + parts.join(" and ") + )) + } +} + /// A PDF being written, shared by export and print: the file's configuration and metadata, then /// its pages ([`Self::page`]), each drawn by an [`Exporter`]. pub(crate) struct Writer { @@ -159,6 +195,8 @@ pub(crate) struct Writer { /// Spot colours written with a Lab alternate, gathered from the exporters ([`Self::absorb`]). lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>, warnings: Vec, + /// RGB content of a CMYK document, gathered from the exporters ([`Self::absorb`]). + rgb_in_cmyk: RgbInCmyk, /// A PDF 1.3 file ([`crate::PdfSettings::pdf13`]): flat, without page groups. pdf13: bool, } @@ -234,7 +272,15 @@ impl Writer { created: created.map(date_time), }; let pdf = write::Writer::new(settings, metadata); - Ok(Self { pdf, cmyk: out.blends_cmyk(cmyk), out: Arc::new(out), lab_spots: vec![], warnings, pdf13: set.pdf13() }) + Ok(Self { + pdf, + cmyk: out.blends_cmyk(cmyk), + out: Arc::new(out), + lab_spots: vec![], + warnings, + rgb_in_cmyk: RgbInCmyk::default(), + pdf13: set.pdf13(), + }) } /// Draw one page: the art of `ex`'s document placed as `sheet` says, then its marks. @@ -323,6 +369,7 @@ impl Writer { self.warnings.push(w); } } + self.rgb_in_cmyk.merge(ex.rgb_in_cmyk); for spot in ex.lab_spots { if !self.lab_spots.iter().any(|(n, _)| *n == spot.0) { self.lab_spots.push(spot); @@ -342,6 +389,7 @@ impl Writer { let bytes = if lab { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) } else { bytes }; let (bytes, more) = self.out.write_catalog(bytes)?; self.warnings.extend(more); + self.warnings.extend(self.rgb_in_cmyk.warning()); Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings)) } } @@ -480,6 +528,8 @@ pub(crate) struct Exporter<'a> { pub out: Arc, /// Colours are converted to the destination (printer's marks aren't). convert: bool, + /// RGB colours and images of a CMYK document drawn so far. + rgb_in_cmyk: RgbInCmyk, /// Text as glyph outlines; else the characters' fills are real text in embedded fonts. outline_text: bool, /// The fonts real text is written in, by face id, with their units per em; `None` for faces @@ -523,6 +573,7 @@ impl<'a> Exporter<'a> { transparent: None, out: Arc::default(), convert: true, + rgb_in_cmyk: RgbInCmyk::default(), outline_text: set.advanced.outline_text, fonts: HashMap::new(), layers: false, @@ -691,6 +742,9 @@ impl Exporter<'_> { /// active colour settings, so the file carries press values. fn col(&mut self, c: &Color) -> krilla::color::Color { let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; + if let (true, true, Color::Rgb { r, g, b }) = (cmyk_doc, self.convert, c) { + self.rgb_in_cmyk.colours.insert([r.to_bits(), g.to_bits(), b.to_bits()]); + } let c = self.out.color(c, self.intent, cmyk_doc, self.convert); if let (Color::Rgb { .. }, Some(note)) = (c, &self.out.srgb_note) { let note = note.clone(); @@ -1641,7 +1695,11 @@ impl Exporter<'_> { let doc = self.doc; let out = self.out.clone(); let img = doc.images.get(key).and_then(|blob| { - let r = if blob.is_cmyk() { + let cmyk_image = blob.is_cmyk(); + if !cmyk_image && self.convert && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { + self.rgb_in_cmyk.images.insert(key.to_string()); + } + let r = if cmyk_image { crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels()) } else { crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels()) diff --git a/crates/pdf/src/tests_cmykblend.rs b/crates/pdf/src/tests_cmykblend.rs index 5655dd0e6..dd3e8183e 100644 --- a/crates/pdf/src/tests_cmykblend.rs +++ b/crates/pdf/src/tests_cmykblend.rs @@ -103,3 +103,30 @@ fn a_cmyk_page_without_blending_through_is_an_isolated_group() { assert!(!t.contains("/Itrue") && t.contains("/S/Alpha"), "grouped {grouped}: {t}"); } } + +fn rgb_warning(d: &Document) -> Option { + export_with_report(d, &PdfOptions::uncompressed()).unwrap().warnings.into_iter().find(|w| w.contains("RGB")) +} + +#[test] +fn rgb_colours_in_a_cmyk_document_are_reported_and_separated() { + let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); + add(&mut d, rect(Rect::new(0.0, 0.0, 50.0, 50.0), Color::rgb(0.11, 0.1, 0.1))); + add(&mut d, rect(Rect::new(50.0, 0.0, 100.0, 50.0), Color::rgb(0.11, 0.1, 0.1))); + add(&mut d, rect(Rect::new(0.0, 50.0, 50.0, 100.0), Color::rgb(0.9, 0.2, 0.2))); + let w = rgb_warning(&d).expect("a warning"); + assert!(w.contains("2 RGB colours"), "{w}"); + // The colours are written as CMYK, not RGB. + let t = text(&d); + assert!(t.contains("k0") && !t.contains("rg\n"), "{t}"); +} + +#[test] +fn cmyk_colours_and_rgb_documents_have_no_rgb_warning() { + let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); + add(&mut d, rect(Rect::new(0.0, 0.0, 50.0, 50.0), Color::cmyk(0.0, 0.0, 0.0, 1.0))); + assert_eq!(rgb_warning(&d), None); + let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Rgb); + add(&mut d, rect(Rect::new(0.0, 0.0, 50.0, 50.0), Color::rgb(0.1, 0.1, 0.1))); + assert_eq!(rgb_warning(&d), None); +} diff --git a/docs/gaps.md b/docs/gaps.md index 132c865f4..5d38a57e3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -335,6 +335,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | G11: PDF export of a CMYK document reports its RGB colours and non-CMYK images, with a count, in the export warnings (#2162); no entry closed, estimates unchanged | | 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Correction to G12: quick Image Trace presets trace at once and leave the panel as it was; the Image Trace button in the Control bar or Properties opens the controls (). Wording only; no entry opened or closed, estimates unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | From 51f8582c25e434c92999f8191bf1b0555a6039be Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:32:06 +0700 Subject: [PATCH 073/208] MCP: a cancelled export keeps no partly drawn tile or raster, and its message is translated (#2165) --- crates/render/src/fx.rs | 5 ++++- crates/render/src/pattern.rs | 4 ++++ 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/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.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/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + 19 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 3082d49aa..1fc0b5a03 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -1284,7 +1284,10 @@ impl Renderer { } } let image = std::sync::Arc::new(pm); - if let Some(((key, made), inputs)) = key.zip(made).zip(inputs) { + // A cancelled export drew part of the content: that raster isn't kept for later frames. + if !vectorcraft_doc::cancel::is_cancelled() + && let Some(((key, made), inputs)) = key.zip(made).zip(inputs) + { let anchor = content.reach.origin(); let p = f.view * anchor; let rel = (x0 - p.x, y0 - p.y); diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index 7008e1ecb..0f31a9e32 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -258,7 +258,11 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, let pm = Arc::new(pm); let (sx, sy) = (w as f64 / pw, h as f64 / ph); let deps = crate::fx::Reads::of_all(def.art.iter().map(|a| &**a)).any().then(|| Deps::of(doc)); + // A cancelled export drew part of the tile: it isn't kept for later frames. CACHE.with(|c| { + if vectorcraft_doc::cancel::is_cancelled() { + return; + } let mut c = c.borrow_mut(); let tiles = &mut c.tiles[ink as usize]; let (t, bytes) = (tiles.tick(), size_of::() + w as usize * h as usize * 4); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c82dac4bd..d30d0166c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5001,3 +5001,4 @@ mark-style Japanese Japonské @msg no symbol named `{n}` žádný symbol s názvem `{n}` @msg a mask has more than one art block maska má více než jeden blok kresby @msg mask art ends outside a mask dictionary kresba masky končí mimo slovník masky +@msg cancelled; nothing was changed zrušeno; nic nebylo změněno diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4dab81245..92074af90 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4538,3 +4538,4 @@ theme Appearance Erscheinungsbild @msg no symbol named `{n}` kein Symbol namens `{n}` @msg a mask has more than one art block eine Maske enthält mehr als einen Grafikblock @msg mask art ends outside a mask dictionary die Maskengrafik endet außerhalb eines Maskenwörterbuchs +@msg cancelled; nothing was changed abgebrochen; es wurde nichts geändert diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7a7faf3ef..5cc3023d9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4953,3 +4953,4 @@ effect Points Σημεία @msg no symbol named `{n}` κανένα σύμβολο με όνομα `{n}` @msg a mask has more than one art block η μάσκα έχει περισσότερα από ένα μπλοκ γραφικών @msg mask art ends outside a mask dictionary τα γραφικά της μάσκας τελειώνουν έξω από το λεξικό της μάσκας +@msg cancelled; nothing was changed ακυρώθηκε· δεν άλλαξε τίποτα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5b9a77e07..ce0da6fe8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4943,3 +4943,4 @@ mark-style Japanese Japonesas @msg no symbol named `{n}` no hay ningún símbolo llamado `{n}` @msg a mask has more than one art block una máscara tiene más de un bloque de ilustración @msg mask art ends outside a mask dictionary la ilustración de la máscara termina fuera de un diccionario de máscara +@msg cancelled; nothing was changed cancelado; no se ha cambiado nada diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index c56c95af8..4e5bb0b35 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4965,3 +4965,4 @@ mark-style Japanese Japanilainen @msg no symbol named `{n}` symbolia nimeltä `{n}` ei ole @msg a mask has more than one art block maskissa on useampi kuin yksi grafiikkalohko @msg mask art ends outside a mask dictionary maskin grafiikka päättyy maskin sanakirjan ulkopuolelle +@msg cancelled; nothing was changed peruutettu; mitään ei muutettu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 747fdce72..6c011a2ce 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5000,3 +5000,4 @@ theme Appearance Apparence @msg no symbol named `{n}` aucun symbole nommé `{n}` @msg a mask has more than one art block un masque contient plusieurs blocs graphiques @msg mask art ends outside a mask dictionary les éléments graphiques du masque se terminent en dehors d’un dictionnaire de masque +@msg cancelled; nothing was changed annulé ; rien n'a été modifié diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index d3de3b4f7..27a6eb2b5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4968,3 +4968,4 @@ imageTrace Outline Körvonal @msg no symbol named `{n}` nincs `{n}` nevű szimbólum @msg a mask has more than one art block egy maszk egynél több grafikai blokkot tartalmaz @msg mask art ends outside a mask dictionary a maszk grafikája a maszk szótárán kívül ér véget +@msg cancelled; nothing was changed megszakítva; semmi sem változott diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cf158f674..7cffb8995 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4578,3 +4578,4 @@ lighting Light Luce @msg no symbol named `{n}` nessun simbolo denominato `{n}` @msg a mask has more than one art block una maschera contiene più di un blocco grafico @msg mask art ends outside a mask dictionary la grafica della maschera termina fuori da un dizionario di maschera +@msg cancelled; nothing was changed annullato; non è stato modificato nulla diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1e7363b0d..abe633da1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4619,3 +4619,4 @@ lighting Light ライト @msg no symbol named `{n}` `{n}` という名前のシンボルはありません @msg a mask has more than one art block マスクに複数のアートブロックがあります @msg mask art ends outside a mask dictionary マスクのアートがマスク辞書の外で終わっています +@msg cancelled; nothing was changed キャンセルしました。何も変更されていません diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f344bd74d..2d36be8b6 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4542,3 +4542,4 @@ lighting Light 광원 @msg no symbol named `{n}` 이름이 `{n}`인 심볼이 없습니다 @msg a mask has more than one art block 마스크에 아트 블록이 두 개 이상 있습니다 @msg mask art ends outside a mask dictionary 마스크 아트가 마스크 사전 밖에서 끝납니다 +@msg cancelled; nothing was changed 취소됨; 변경된 내용이 없습니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f393772ab..08b10404f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4846,3 +4846,4 @@ mark-style Japanese Japońskie @msg no symbol named `{n}` brak symbolu o nazwie `{n}` @msg a mask has more than one art block maska ma więcej niż jeden blok grafiki @msg mask art ends outside a mask dictionary grafika maski kończy się poza słownikiem maski +@msg cancelled; nothing was changed anulowano; nic nie zostało zmienione diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 52ddf636d..4fe9c2ca5 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4918,3 +4918,4 @@ mark-style Japanese Japonesas @msg no symbol named `{n}` nenhum símbolo chamado `{n}` @msg a mask has more than one art block uma máscara contém mais de um bloco de arte @msg mask art ends outside a mask dictionary a arte da máscara termina fora de um dicionário de máscara +@msg cancelled; nothing was changed cancelado; nada foi alterado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d7967aeba..c3ca047a8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4574,3 +4574,4 @@ lighting Light Свет @msg no symbol named `{n}` нет символа с именем `{n}` @msg a mask has more than one art block маска содержит более одного блока графики @msg mask art ends outside a mask dictionary графика маски заканчивается за пределами словаря маски +@msg cancelled; nothing was changed отменено; ничего не изменено diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 1f623cfbc..367aeab82 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4957,3 +4957,4 @@ theme Appearance Utseende @msg no symbol named `{n}` ingen symbol med namnet `{n}` @msg a mask has more than one art block en mask innehåller mer än ett grafikblock @msg mask art ends outside a mask dictionary maskens grafik slutar utanför en maskordlista +@msg cancelled; nothing was changed avbrutet; inget ändrades diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 509f2507f..22c386082 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4558,3 +4558,4 @@ lighting Light Світло @msg no symbol named `{n}` немає символу з назвою `{n}` @msg a mask has more than one art block маска містить більше одного блоку графіки @msg mask art ends outside a mask dictionary графіка маски закінчується за межами словника маски +@msg cancelled; nothing was changed скасовано; нічого не змінено diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 250c3e2bc..62232418c 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4558,3 +4558,4 @@ theme Appearance Giao diện @msg no symbol named `{n}` không có biểu tượng nào tên `{n}` @msg a mask has more than one art block mặt nạ có nhiều hơn một khối đồ họa @msg mask art ends outside a mask dictionary đồ họa mặt nạ kết thúc bên ngoài từ điển mặt nạ +@msg cancelled; nothing was changed đã hủy; không có gì thay đổi diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index bc23a1fcf..3e5782fd2 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4106,3 +4106,4 @@ lighting Light 光源 Movie Clip 影片剪辑 @msg name is empty 名称为空 @msg no symbol named `{n}` 没有名为 `{n}` 的符号 +@msg cancelled; nothing was changed 已取消;未做任何更改 From a71f8eb56a989975c3e8d92dab1345362f5ab002 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:48:49 +0700 Subject: [PATCH 074/208] Persistence: startup reads at most 256 MB of stored colour profiles (#2174) --- crates/engine/src/store.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 9de359b44..8f9712aba 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -28,6 +28,8 @@ pub const MAX_STORE_FILES: usize = 1000; pub const MAX_PRESET_FILE: u64 = 1 << 20; /// The largest ICC profile file read or loaded (CMYK profiles with large tables are a few MB). pub const MAX_PROFILE_FILE: u64 = 32 << 20; +/// The most stored ICC profile bytes read at startup, all files together (the rest are skipped). +pub const MAX_PROFILE_STORE: u64 = 256 << 20; /// The extension of a stored ICC profile. const PROFILE_EXT: &str = "icc"; @@ -151,9 +153,16 @@ impl Session { /// Keep the ICC profiles this session loads in `dir`, and register the ones already there. /// Call it before anything that names a profile (restoring the preferences, the first command). pub fn attach_profile_store(&mut self, dir: PathBuf) { + let mut left = MAX_PROFILE_STORE; for path in list_files(&dir, PROFILE_EXT) { - let loaded = - read_capped(&path, MAX_PROFILE_FILE).and_then(|bytes| vectorcraft_color::cms::register_icc(&bytes, None).map_err(|e| e.to_string())); + if left == 0 { + log::warn!("skipping the other stored colour profiles: they hold more than {MAX_PROFILE_STORE} bytes together"); + break; + } + let loaded = read_capped(&path, MAX_PROFILE_FILE.min(left)).and_then(|bytes| { + left -= bytes.len() as u64; + vectorcraft_color::cms::register_icc(&bytes, None).map_err(|e| e.to_string()) + }); if let Err(e) = loaded { log::warn!("skipping the stored colour profile {}: {e}", path.display()); } From f0bb2760c9cab484129c1253ccb8bc361c20083f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 17:53:11 -0700 Subject: [PATCH 075/208] Accessibility: buttons, toggles, checkboxes and sliders have names for screen readers The controls that the shared widgets draw had no name in the accessibility tree: icon, flat, primary, secondary and split buttons, chips, toggles, checkboxes, radio buttons, tabs, sliders, the angle dial, the Reference Point's squares, the spinner and preset arrows and the searchable combo boxes. Screen readers announced each clickable one as an unnamed element of unknown role, and a disabled icon button was not in the tree. The Color Guide's library button, the Color Themes Save button and the status bar's artboard buttons also had no name. Each helper in widgets.rs except color_field now gives its response a role, a name and its state. The name is the text, label or translated tool tip the helper shows. The spinner, the preset arrow, the angle dial, the harmony wheel and the Reference Point's squares, which show none, are named "Step up or down", "Presets", "Angle", "Color wheel" and Top Left to Bottom Right. A toggle, checkbox, radio button, tab or Reference Point square reports whether it is on, an icon button reports that it is pressed while it is lit, the angle dial reports its angle, and a searchable combo box reports the text it shows. A checkbox or radio button drawn without text is named by the label beside it. The Tools panel's tool buttons, flyout rows and floating strips are named by their tools' tool tips, with the active tool pressed. The title bar's column toggle, the tear-off bars, a floating strip's close box and the Color, Gradient and None buttons are named by their tool tips. The status bar's First, Previous, Next and Last Artboard buttons now have tool tips, and the Fill and Stroke chips' tool tips are translated. The six new strings (the four artboard tips, "Color wheel" and "Step up or down") have rows in every catalog. split_button, chip_button, color_slider, channel_slider and the dialog form's slider_field and slider_rail keep their arguments. New _named variants beside them take the arrow's tool tip, the chip's tool tip or the slider's name, and every caller in the tree uses them. Docs: docs/target-app-parity.md. --- crates/ui-egui/src/chrome.rs | 27 +- crates/ui-egui/src/dialogs/brush_options.rs | 2 +- crates/ui-egui/src/dialogs/color_picker.rs | 2 +- crates/ui-egui/src/dialogs/effect.rs | 4 +- crates/ui-egui/src/dialogs/flatten.rs | 6 +- crates/ui-egui/src/dialogs/form.rs | 39 ++- crates/ui-egui/src/dialogs/freehand.rs | 2 +- crates/ui-egui/src/dialogs/path_ops.rs | 2 +- .../ui-egui/src/dialogs/perspective_grid.rs | 5 +- crates/ui-egui/src/dialogs/print.rs | 2 +- crates/ui-egui/src/dialogs/revolve.rs | 12 +- crates/ui-egui/src/dialogs/shortcut_import.rs | 2 +- crates/ui-egui/src/dialogs/slices.rs | 17 +- crates/ui-egui/src/dialogs/swatch_options.rs | 2 +- crates/ui-egui/src/dialogs/transform_each.rs | 4 +- crates/ui-egui/src/dialogs/variables.rs | 8 +- crates/ui-egui/src/i18n/bg.tsv | 6 + crates/ui-egui/src/i18n/cs.tsv | 6 + crates/ui-egui/src/i18n/de.tsv | 6 + crates/ui-egui/src/i18n/el.tsv | 6 + crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/fi.tsv | 6 + crates/ui-egui/src/i18n/fr.tsv | 6 + crates/ui-egui/src/i18n/hu.tsv | 6 + crates/ui-egui/src/i18n/it.tsv | 6 + crates/ui-egui/src/i18n/ja.tsv | 6 + crates/ui-egui/src/i18n/ko.tsv | 6 + crates/ui-egui/src/i18n/pl.tsv | 6 + crates/ui-egui/src/i18n/pt-br.tsv | 6 + crates/ui-egui/src/i18n/ru.tsv | 6 + crates/ui-egui/src/i18n/sv.tsv | 6 + crates/ui-egui/src/i18n/uk.tsv | 6 + crates/ui-egui/src/i18n/vi.tsv | 6 + crates/ui-egui/src/i18n/zh-hans.tsv | 6 + crates/ui-egui/src/i18n/zh-hant.tsv | 6 + crates/ui-egui/src/panels/align.rs | 17 + crates/ui-egui/src/panels/appearance.rs | 2 +- crates/ui-egui/src/panels/color.rs | 6 +- crates/ui-egui/src/panels/color_guide.rs | 18 +- crates/ui-egui/src/panels/color_themes.rs | 13 +- crates/ui-egui/src/panels/image_trace.rs | 3 +- crates/ui-egui/src/panels/mod.rs | 12 +- crates/ui-egui/src/panels/navigator.rs | 2 +- crates/ui-egui/src/panels/pathfinder.rs | 17 + crates/ui-egui/src/toolbar.rs | 55 +++- crates/ui-egui/src/widgets.rs | 290 +++++++++++++++++- docs/target-app-parity.md | 1 + 47 files changed, 629 insertions(+), 57 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index c1eb9cb43..01a70ca97 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -322,8 +322,9 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { if ui.link(egui::RichText::new(tl!("Style:")).size(12.0).color(t.text).underline()).clicked() { app.ui.open_panel = Some("graphicStyles".into()); } - let resp = widgets::chip_button(ui, 22.0, true, |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r)) - .on_hover_text(tl!("Graphic Style")); + let resp = widgets::chip_button_named(ui, 22.0, true, tl!("Graphic Style"), |ui, r| { + crate::panels::graphic_styles::paint_linked(app, ui, r) + }); egui::Popup::menu(&resp).show(|ui| crate::panels::graphic_styles::picker(app, ui)); // Recolor Artwork, when the selection has colours to recolour. let colored = app.session.active().is_some_and(|st| crate::canvas::has_colors(&st.doc, &st.selection.objects)); @@ -681,16 +682,16 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let nab = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0); let cur = crate::panels::artboards::selected(app, nab); let mut go = None; - for (icon, to) in [("chevrons-left", "first"), ("chevron-left", "previous")] { - if widgets::icon_button_enabled(ui, icon, "", false, cur > 0, 18.0).clicked() { + for (icon, to, tip) in [("chevrons-left", "first", tl!("First Artboard")), ("chevron-left", "previous", tl!("Previous Artboard"))] { + if widgets::icon_button_enabled(ui, icon, tip, false, cur > 0, 18.0).clicked() { go = Some(json!(to)); } } if let Some(i) = artboard_list(app, ui, cur) { go = Some(json!(i)); } - for (icon, to) in [("chevron-right", "next"), ("chevrons-right", "last")] { - if widgets::icon_button_enabled(ui, icon, "", false, cur + 1 < nab, 18.0).clicked() { + for (icon, to, tip) in [("chevron-right", "next", tl!("Next Artboard")), ("chevrons-right", "last", tl!("Last Artboard"))] { + if widgets::icon_button_enabled(ui, icon, tip, false, cur + 1 < nab, 18.0).clicked() { go = Some(json!(to)); } } @@ -1132,6 +1133,20 @@ mod tests { assert!(app.run("view.goToArtboard", json!({"index": "sideways"})).is_err()); } + /// Screen readers name the artboard navigator's four buttons, the ones that lead nowhere from + /// the first artboard disabled. + #[test] + fn screen_readers_name_the_artboard_navigators_buttons() { + use crate::widgets::accessibility_tests::{named, tree}; + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); + let tree = tree(|ui| super::status_bar(&mut app, ui)); + for (label, enabled) in [("First Artboard", false), ("Previous Artboard", false), ("Next Artboard", true), ("Last Artboard", true)] { + let n = named(&tree, label); + assert_eq!((n.role(), n.is_disabled()), (egui::accesskit::Role::Button, !enabled), "{label}"); + } + } + /// One headless frame of the status bar, with room above it for the artboard list; returns the /// texts painted and where they are. fn status_texts(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { diff --git a/crates/ui-egui/src/dialogs/brush_options.rs b/crates/ui-egui/src/dialogs/brush_options.rs index bc9964495..354bbb358 100644 --- a/crates/ui-egui/src/dialogs/brush_options.rs +++ b/crates/ui-egui/src/dialogs/brush_options.rs @@ -207,7 +207,7 @@ fn bristle_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { }); let rail = Tokens::get(ui.ctx()).input_border; for (key, text, (lo, hi)) in BRISTLE { - widgets::label_row(ui, tl!(text), LABEL_W, |ui| form::slider_field(ui, d, key, lo..=hi, "%", &|_| rail)); + widgets::label_row(ui, tl!(text), LABEL_W, |ui| form::slider_field_named(ui, d, key, tl!(text), lo..=hi, "%", &|_| rail)); } ui.add_space(8.0); let p = params(d); diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index 1d27072d4..ea1131bb8 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -211,7 +211,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ui.add_space(6.0); let track = |z: f32| c32(&Picked::of(slider_color(channel, z, x, y)).snapped(web).color); - if let (Some(z), _) = widgets::channel_slider(ui, "cp-slider", z, vec2(22.0, FIELD), &track) { + if let (Some(z), _) = widgets::channel_slider_named(ui, "cp-slider", tl!(channel.label()), z, vec2(22.0, FIELD), &track) { pick = Some(field_pick(channel, z, x, y)); } } diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index c103da554..669532ef2 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -428,7 +428,7 @@ fn zig_zag_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit widgets::subheader(ui, tl!("Options")); // Size is a share of the object's size (Relative) or a distance (Absolute). widgets::label_row(ui, tl!("Size:"), LABEL_W, |ui| { - form::slider_rail(ui, d, "size", 0.0..=100.0, &track); + form::slider_rail_named(ui, d, "size", tl!("Size:"), 0.0..=100.0, &track); if !relative { form::length(ui, d, "size", unit, FIELD_W); } else if let Some(n) = widgets::range_field(ui, ("zz", "size"), d.f64("size", 10.0), 0.0..=100.0, "%", 0, FIELD_W) { @@ -444,7 +444,7 @@ fn zig_zag_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit } }); widgets::label_row(ui, tl!("Ridges per segment:"), LABEL_W, |ui| { - form::slider_rail(ui, d, "ridges", 0.0..=100.0, &track); + form::slider_rail_named(ui, d, "ridges", tl!("Ridges per segment:"), 0.0..=100.0, &track); if let Some(n) = widgets::range_field(ui, ("zz", "ridges"), d.f64("ridges", 4.0), 0.0..=100.0, "", 0, FIELD_W) { d.fields.insert("ridges".into(), json!(n)); } diff --git a/crates/ui-egui/src/dialogs/flatten.rs b/crates/ui-egui/src/dialogs/flatten.rs index 9d73431d7..194f2bf17 100644 --- a/crates/ui-egui/src/dialogs/flatten.rs +++ b/crates/ui-egui/src/dialogs/flatten.rs @@ -191,7 +191,11 @@ pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions ui.spacing_mut().item_spacing.x = 6.0; dim(ui, tl!("Rasters")); let rail = (ui.available_width() - FIELD - 56.0).clamp(80.0, 200.0); - if let (Some(x), _) = widgets::color_slider(ui, (id, "balance-slider"), (o.balance / 100.0) as f32, rail, &|_| t.input_border) { + if let (Some(x), _) = + widgets::color_slider_named(ui, (id, "balance-slider"), tl!("Raster/Vector Balance:"), (o.balance / 100.0) as f32, rail, &|_| { + t.input_border + }) + { o.balance = (x as f64 * 100.0).round(); } dim(ui, tl!("Vectors")); diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 460eda4b2..82b3cb8db 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -218,7 +218,7 @@ pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { let max = vectorcraft_geom::shapes::GRID_SKEW_MAX; row_label(ui, tl!("Skew:")); ui.vertical(|ui| { - ui.horizontal(|ui| slider_field(ui, d, &k, -max..=max, "%", &|_| rail)); + ui.horizontal(|ui| slider_field_named(ui, d, &k, tl!("Skew:"), -max..=max, "%", &|_| rail)); rail_ends(ui, ends); }); ui.end_row(); @@ -315,7 +315,7 @@ pub(super) fn param_fields( }); } Value::Bool(b) => { - if crate::widgets::check(ui, "", b, true) { + if crate::widgets::check_box(ui, &humanized(&k), b, true) { d.fields.insert(k, json!(!b)); changed = true; } @@ -384,7 +384,7 @@ pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String, } ParamSpec::Bool { .. } => { let b = cur.as_bool().unwrap_or(false); - crate::widgets::check(ui, "", b, true).then(|| json!(!b)) + crate::widgets::check_box(ui, &humanize(k), b, true).then(|| json!(!b)) } ParamSpec::Choice { options, .. } => { let labels: Vec<&str> = options.iter().map(String::as_str).collect(); @@ -476,11 +476,12 @@ pub(super) fn slider_w( let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { ui.add_sized([label_w, 22.0], egui::Label::new(egui::RichText::new(tl!(label)).color(t.text))); - slider_field(ui, d, key, range, suffix, track); + slider_field_named(ui, d, key, tl!(label), range, suffix, track); }); } /// The rail and value field of [`slider`] without its label (for a row that draws its own). +#[allow(dead_code)] pub(super) fn slider_field( ui: &mut egui::Ui, d: &mut Dialog, @@ -488,10 +489,23 @@ pub(super) fn slider_field( range: std::ops::RangeInclusive, suffix: &str, track: &dyn Fn(f32) -> egui::Color32, +) { + slider_field_named(ui, d, key, "", range, suffix, track); +} + +/// [`slider_field`] whose rail screen readers call `name` (the row's label, already translated). +pub(super) fn slider_field_named( + ui: &mut egui::Ui, + d: &mut Dialog, + key: &str, + name: &str, + range: std::ops::RangeInclusive, + suffix: &str, + track: &dyn Fn(f32) -> egui::Color32, ) { let (min, max) = (*range.start(), *range.end()); let v = d.f64(key, 0.0).clamp(min, max); - slider_rail(ui, d, key, range, track); + slider_rail_named(ui, d, key, name, range, track); if let Some(n) = crate::widgets::plain_field(ui, ("dlg-field", key), v, suffix, 0, 52.0).map(|x| x.round().clamp(min, max)).filter(|n| *n != v) { d.fields.insert(key.into(), json!(n)); } @@ -499,10 +513,23 @@ pub(super) fn slider_field( /// The rail of [`slider_field`] alone: dragging it sets the number `d.fields[key]` to a whole /// number in `range` (a value past the range shows at its end until the rail moves). +#[allow(dead_code)] pub(super) fn slider_rail(ui: &mut egui::Ui, d: &mut Dialog, key: &str, range: std::ops::RangeInclusive, track: &dyn Fn(f32) -> egui::Color32) { + slider_rail_named(ui, d, key, "", range, track); +} + +/// [`slider_rail`] that screen readers call `name` (already translated). +pub(super) fn slider_rail_named( + ui: &mut egui::Ui, + d: &mut Dialog, + key: &str, + name: &str, + range: std::ops::RangeInclusive, + track: &dyn Fn(f32) -> egui::Color32, +) { let (min, max) = (*range.start(), *range.end()); let v = d.f64(key, 0.0).clamp(min, max); - let (dragged, _) = crate::widgets::color_slider(ui, ("dlg-slider", key), ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track); + let (dragged, _) = crate::widgets::color_slider_named(ui, ("dlg-slider", key), name, ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track); if let Some(n) = dragged.map(|x| (min + x as f64 * (max - min)).round()).filter(|n| *n != v) { d.fields.insert(key.into(), json!(n)); } diff --git a/crates/ui-egui/src/dialogs/freehand.rs b/crates/ui-egui/src/dialogs/freehand.rs index 8918cd079..f87cef445 100644 --- a/crates/ui-egui/src/dialogs/freehand.rs +++ b/crates/ui-egui/src/dialogs/freehand.rs @@ -71,7 +71,7 @@ fn fidelity(ui: &mut egui::Ui, d: &mut Dialog) { let mut new = None; widgets::label_row(ui, tl!("Fidelity:"), label_w, |ui| { let at = ((v.clamp(min, max) - min) / (max - min)) as f32; - if let (Some(x), _) = widgets::color_slider(ui, "freehand-fidelity", at, form::SLIDER_WIDTH, &|_| t.input_border) { + if let (Some(x), _) = widgets::color_slider_named(ui, "freehand-fidelity", tl!("Fidelity:"), at, form::SLIDER_WIDTH, &|_| t.input_border) { new = Some(((min + x as f64 * (max - min)) * 10.0).round() / 10.0); } if let Some(x) = widgets::plain_field(ui, "freehand-fidelity-field", v, "pt", 1, 64.0) { diff --git a/crates/ui-egui/src/dialogs/path_ops.rs b/crates/ui-egui/src/dialogs/path_ops.rs index 8bf15f7a3..162435462 100644 --- a/crates/ui-egui/src/dialogs/path_ops.rs +++ b/crates/ui-egui/src/dialogs/path_ops.rs @@ -111,7 +111,7 @@ fn simplify_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { }); ui.add_space(8.0); form::row_label(ui, tl!("Corner Point Angle Threshold:")); - ui.horizontal(|ui| form::slider_field(ui, d, "angleThreshold", 0.0..=180.0, "°", &|_| rail)); + ui.horizontal(|ui| form::slider_field_named(ui, d, "angleThreshold", tl!("Corner Point Angle Threshold:"), 0.0..=180.0, "°", &|_| rail)); ui.add_space(8.0); form::check(ui, d, "straightLines", tl!("Convert to Straight Lines")); form::check(ui, d, "showOriginal", tl!("Show Original Path")); diff --git a/crates/ui-egui/src/dialogs/perspective_grid.rs b/crates/ui-egui/src/dialogs/perspective_grid.rs index b6d6af22d..5734ca7fc 100644 --- a/crates/ui-egui/src/dialogs/perspective_grid.rs +++ b/crates/ui-egui/src/dialogs/perspective_grid.rs @@ -332,8 +332,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { widgets::label_row(ui, tl!("Opacity:"), LABEL, |ui| { let v = d.f64("opacity", 50.0).clamp(0.0, 100.0); let track = |x: f32| egui::Color32::from_gray((60.0 + x * 160.0) as u8); - let mut new = - widgets::color_slider(ui, "persp-opacity", (v / 100.0) as f32, form::SLIDER_WIDTH, &track).0.map(|x| (f64::from(x) * 100.0).round()); + let mut new = widgets::color_slider_named(ui, "persp-opacity", tl!("Opacity:"), (v / 100.0) as f32, form::SLIDER_WIDTH, &track) + .0 + .map(|x| (f64::from(x) * 100.0).round()); if let Some(x) = widgets::plain_field(ui, "persp-opacity-field", v, "%", 0, 52.0) { new = Some(x.round().clamp(0.0, 100.0)); } diff --git a/crates/ui-egui/src/dialogs/print.rs b/crates/ui-egui/src/dialogs/print.rs index 41c7cbc99..ea4f58d39 100644 --- a/crates/ui-egui/src/dialogs/print.rs +++ b/crates/ui-egui/src/dialogs/print.rs @@ -688,7 +688,7 @@ fn output(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result) { for ink in inks { let name = ink["name"].as_str().unwrap_or_default(); let on = ink["print"].as_bool().unwrap_or(true); - if widgets::check(ui, "", on, true) { + if widgets::check_box(ui, name, on, true) { set_ink(d, name, "print", json!(!on)); } let spot = if ink["spot"] == true { format!(" ({})", tl!("spot")) } else { String::new() }; diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index 73f5cee55..28cb93353 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -371,10 +371,14 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, }; let rail_min = from(range.0); let rail = rail_width(ui) + VALUE_W; - let slider = - widgets::color_slider(ui, ("revolve-slider", key), ((value - rail_min) / (rail_max - rail_min)).clamp(0.0, 1.0) as f32, rail, &|_| { - t.input_border - }); + let slider = widgets::color_slider_named( + ui, + ("revolve-slider", key), + name, + ((value - rail_min) / (rail_max - rail_min)).clamp(0.0, 1.0) as f32, + rail, + &|_| t.input_border, + ); if let (Some(n), _) = slider { next = Some(rail_min + f64::from(n) * (rail_max - rail_min)); } diff --git a/crates/ui-egui/src/dialogs/shortcut_import.rs b/crates/ui-egui/src/dialogs/shortcut_import.rs index bc5de6171..218cc218f 100644 --- a/crates/ui-egui/src/dialogs/shortcut_import.rs +++ b/crates/ui-egui/src/dialogs/shortcut_import.rs @@ -835,7 +835,7 @@ fn rows_section(ui: &mut egui::Ui, d: &mut Dialog, review: &mut Review, statuses let (status, chord) = statuses.get(i).cloned().unwrap_or((Status::NeedsChoice, None)); let Some(r) = review.rows.get_mut(i) else { continue }; ui.horizontal(|ui| { - if widgets::check(ui, "", r.on, true) { + if widgets::check_box(ui, &r.name, r.on, true) { r.on = !r.on; } let place = crate::i18n::fmt(tl!("Line {line}"), &[("line", &r.line.to_string())]); diff --git a/crates/ui-egui/src/dialogs/slices.rs b/crates/ui-egui/src/dialogs/slices.rs index da45a477f..723f5be62 100644 --- a/crates/ui-egui/src/dialogs/slices.rs +++ b/crates/ui-egui/src/dialogs/slices.rs @@ -141,7 +141,7 @@ fn divide_section(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, keys: ui.add_enabled_ui(enabled, |ui| { ui.indent(on, |ui| { ui.horizontal(|ui| { - if widgets::radio(ui, "", !by_size, enabled) { + if widgets::radio_button(ui, labels[1], !by_size, enabled) { d.fields.insert(mode.into(), json!("count")); } if let Some(n) = widgets::plain_field(ui, ("divide", count), d.f64(count, 2.0), "", 0, 50.0) { @@ -150,7 +150,7 @@ fn divide_section(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, keys: widgets::dim_label(ui, labels[1]); }); ui.horizontal(|ui| { - if widgets::radio(ui, "", by_size, enabled) { + if widgets::radio_button(ui, tl!("per slice"), by_size, enabled) { d.fields.insert(mode.into(), json!("size")); } form::length(ui, d, size, app.session.general_unit(), 70.0); @@ -244,6 +244,19 @@ mod tests { assert!(l["slices"].as_array().unwrap().iter().any(|s| s["options"]["kind"] == "image" && s["source"] == "object")); } + /// Screen readers name Divide Slices' radio buttons by the text after their fields. + #[test] + fn screen_readers_name_the_divide_radio_buttons() { + use crate::widgets::accessibility_tests::{role_and_state, tree}; + use egui::accesskit::{Role, Toggled}; + let mut app = app(); + app.run("object.slice.divide", json!({})).unwrap(); + set(&mut app, "divideRows", json!(true)); + let shown = tree(|ui| super::super::show(&mut app, ui.ctx())); + assert_eq!(role_and_state(&shown, "slices down, evenly spaced"), (Role::RadioButton, Some(Toggled::True))); + assert_eq!(role_and_state(&shown, "per slice"), (Role::RadioButton, Some(Toggled::False))); + } + #[test] fn divide_slices_cuts_by_count_or_size() { let mut app = app(); diff --git a/crates/ui-egui/src/dialogs/swatch_options.rs b/crates/ui-egui/src/dialogs/swatch_options.rs index 07674ef99..51bc62f5e 100644 --- a/crates/ui-egui/src/dialogs/swatch_options.rs +++ b/crates/ui-egui/src/dialogs/swatch_options.rs @@ -214,7 +214,7 @@ fn sliders(ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new(*lbl).size(12.5).color(t.text))); let v = comps.get(i).copied().unwrap_or(0.0); let track = |x: f32| crate::panels::c32(&track_color(mode, &comps, i, x)); - if let (Some(nv), _) = widgets::color_slider(ui, ("swatch-slider", i), mode.unit(i, v), 200.0, &track) { + if let (Some(nv), _) = widgets::color_slider_named(ui, ("swatch-slider", i), lbl, mode.unit(i, v), 200.0, &track) { let mut c2 = comps.clone(); c2[i] = mode.value_at(i, nv); new = Some((from_components(mode, &c2), c2)); diff --git a/crates/ui-egui/src/dialogs/transform_each.rs b/crates/ui-egui/src/dialogs/transform_each.rs index 51553051b..1d3f070cb 100644 --- a/crates/ui-egui/src/dialogs/transform_each.rs +++ b/crates/ui-egui/src/dialogs/transform_each.rs @@ -56,7 +56,7 @@ pub(super) fn sections(ui: &mut egui::Ui, d: &mut Dialog, units: vectorcraft_doc widgets::subheader(ui, tl!("Scale")); for (key, label) in axes("scaleH", "scaleV") { widgets::label_row(ui, label, LABEL_W, |ui| { - form::slider_rail(ui, d, key, 0.0..=200.0, &track); + form::slider_rail_named(ui, d, key, label, 0.0..=200.0, &track); if let Some(n) = widgets::range_field(ui, ("te", key), d.f64(key, 100.0), -MAX_SCALE..=MAX_SCALE, "%", 2, FIELD_W) { d.fields.insert(key.into(), json!(n)); } @@ -65,7 +65,7 @@ pub(super) fn sections(ui: &mut egui::Ui, d: &mut Dialog, units: vectorcraft_doc widgets::subheader(ui, tl!("Move")); for (key, label) in axes("moveH", "moveV") { widgets::label_row(ui, label, LABEL_W, |ui| { - form::slider_rail(ui, d, key, -1000.0..=1000.0, &track); + form::slider_rail_named(ui, d, key, label, -1000.0..=1000.0, &track); form::length(ui, d, key, units, FIELD_W); }); } diff --git a/crates/ui-egui/src/dialogs/variables.rs b/crates/ui-egui/src/dialogs/variables.rs index ee30d81a8..12ee10020 100644 --- a/crates/ui-egui/src/dialogs/variables.rs +++ b/crates/ui-egui/src/dialogs/variables.rs @@ -212,7 +212,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { VariableKind::Graph => { text_area(ui, d, &key, 220.0); } - VariableKind::Visibility => checkbox(ui, d, &key), + VariableKind::Visibility => checkbox(ui, d, &key, &v.name), } ui.end_row(); } @@ -232,10 +232,10 @@ fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Respo r } -/// A checkbox for `key`, written back as it is toggled. -fn checkbox(ui: &mut egui::Ui, d: &mut Dialog, key: &str) { +/// A checkbox for `key`, written back as it is toggled, which screen readers call `name`. +fn checkbox(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str) { let b = d.bool(key); - if widgets::check(ui, "", b, true) { + if widgets::check_box(ui, name, b, true) { d.fields.insert(key.into(), json!(!b)); } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e3e8d7a51..5f93a3fd1 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -492,6 +492,7 @@ Color Model: Цветен модел: Color Models Цветни модели Color Picker Палитра с цвят + Color wheel Цветово колело Color Picker… Палитра с цвят… Color Reduction: Намаляване на цветовете: Color Settings Цветови настройки @@ -1175,6 +1176,10 @@ glyph {id} глиф {id} Glyphs Глифи Go to Artboard Към работна област + First Artboard Първа работна област + Previous Artboard Предишна работна област + Next Artboard Следваща работна област + Last Artboard Последна работна област Go To Link Към връзката GPL palettes keep solid colours only, as RGB. Палитрите GPL запазват само плътни цветове като RGB. GPU Performance Производителност на графичния процесор @@ -4284,6 +4289,7 @@ effect Soft Меко Reset all Нулиране на всичко Reset to default Връщане към по подразбиране Reset all Revolve settings to their defaults. Нулиране на всички настройки за въртене към техните стойности по подразбиране. + Step up or down Стъпка нагоре или надолу Drag values or labels. Double-click a value to type. Плъзнете стойности или етикети. Кликнете двукратно върху стойност за въвеждане. Drag rings to rotate. Shift snaps to 15°. Плъзнете пръстените за завъртане. Shift прилепва към 15°. Drag the light, or its rings. Shift snaps to 15°. Плъзнете светлината или нейните пръстени. Shift прилепва към 15°. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 979004bf3..6ca2a3d9c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -808,6 +808,7 @@ Reset all Obnovit vše Reset to default Obnovit výchozí hodnotu Reset all Revolve settings to their defaults. Obnovit výchozí hodnoty všech nastavení rotačního tělesa. + Step up or down O krok nahoru nebo dolů Drag values or labels. Double-click a value to type. Upravte hodnoty přetažením čísel nebo popisků. Dvojím kliknutím na hodnotu ji zadáte. Drag rings to rotate. Shift snaps to 15°. Otáčejte přetažením prstenců. Shift nastaví kroky po 15°. Drag the light, or its rings. Shift snaps to 15°. Přetáhněte světlo nebo jeho prstence. Shift nastaví kroky po 15°. @@ -855,6 +856,10 @@ lighting Light Světlo Export Keyboard Shortcuts… Exportovat klávesové zkratky… File Info Dialog Dialogové okno Informace o souboru Go to Artboard Přejít na pracovní plochu + First Artboard První pracovní plocha + Previous Artboard Předchozí pracovní plocha + Next Artboard Další pracovní plocha + Last Artboard Poslední pracovní plocha Graphic Style Options… Možnosti grafického stylu… Symbol Options Možnosti symbolu Symbol Options… Možnosti symbolu… @@ -1935,6 +1940,7 @@ lighting Light Světlo Variation Variace Color Models Barevné modely Color Picker Výběr barvy + Color wheel Barevné kolo Color Swatches Položky vzorníku Only Web Colors Jen webové barvy Original: click to restore Původní: kliknutím obnovíte diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fdbab8116..9f7ee9465 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -492,6 +492,7 @@ Color Model: Farbmodell: Color Models Farbmodelle Color Picker Farbwähler + Color wheel Farbkreis Color Picker… Farbwähler… Color Reduction: Farbreduktion: Color Settings Farbeinstellungen @@ -1183,6 +1184,10 @@ glyph {id} Glyphe {id} Glyphs Glyphen Go to Artboard Gehe zu Zeichenfläche + First Artboard Erste Zeichenfläche + Previous Artboard Vorherige Zeichenfläche + Next Artboard Nächste Zeichenfläche + Last Artboard Letzte Zeichenfläche Go To Link Gehe zu Verknüpfung GPL palettes keep solid colours only, as RGB. GPL-Paletten enthalten nur einfarbige Farben, als RGB. GPU Performance GPU-Leistung @@ -4304,6 +4309,7 @@ theme Appearance Erscheinungsbild Drag rings to rotate. Shift snaps to 15°. Ziehen Sie die Ringe, um zu drehen. Mit Shift in 15°-Schritten. Drag the light, or its rings. Shift snaps to 15°. Ziehen Sie das Licht oder seine Ringe. Mit Shift in 15°-Schritten. Drag values or labels. Double-click a value to type. Ziehen Sie Werte oder Beschriftungen. Doppelklicken Sie auf einen Wert, um ihn einzugeben. + Step up or down Schrittweise erhöhen oder verringern Intensity Intensität Keep visible surfaces only when expanding Beim Umwandeln nur sichtbare Flächen behalten Lighting Beleuchtung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 608c2749f..66977f54d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -467,6 +467,7 @@ Color Model: Μοντέλο χρώματος: Color Models Μοντέλα χρώματος Color Picker Επιλογέας χρώματος + Color wheel Χρωματικός τροχός Color Picker… Επιλογέας χρώματος… Color Reduction: Μείωση χρωμάτων: Color Settings Ρυθμίσεις χρωμάτων @@ -1138,6 +1139,10 @@ glyph {id} γλύφος {id} Glyphs Γλύφοι Go to Artboard Μετάβαση στον πίνακα σχεδίασης + First Artboard Πρώτος πίνακας σχεδίασης + Previous Artboard Προηγούμενος πίνακας σχεδίασης + Next Artboard Επόμενος πίνακας σχεδίασης + Last Artboard Τελευταίος πίνακας σχεδίασης Go To Link Μετάβαση στη σύνδεση GPL palettes keep solid colours only, as RGB. Οι παλέτες GPL διατηρούν μόνο συμπαγή χρώματα σε RGB. GPU Performance Απόδοση GPU @@ -4236,6 +4241,7 @@ theme Appearance Εμφάνιση Reset all Επαναφορά όλων Reset to default Επαναφορά στην προεπιλογή Reset all Revolve settings to their defaults. Επαναφορά όλων των ρυθμίσεων περιστροφής γύρω από άξονα στις προεπιλογές. + Step up or down Ένα βήμα πάνω ή κάτω Drag values or labels. Double-click a value to type. Σύρετε τιμές ή ετικέτες. Κάντε διπλό κλικ σε μια τιμή για πληκτρολόγηση. Drag rings to rotate. Shift snaps to 15°. Σύρετε τους δακτυλίους για περιστροφή. Με Shift γίνεται προσκόλληση ανά 15°. Drag the light, or its rings. Shift snaps to 15°. Σύρετε την πηγή φωτός ή τους δακτυλίους της. Με Shift γίνεται προσκόλληση ανά 15°. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 282e5dd11..1067afa8b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -507,6 +507,7 @@ Color Model: Modelo de color: Color Models Modelos de color Color Picker Selector de color + Color wheel Rueda de color Color Picker… Selector de color… Color Reduction: Reducción de colores: Color Settings Ajustes de color @@ -1200,6 +1201,10 @@ glyph {id} glifo {id} Glyphs Glifos Go to Artboard Ir a mesa de trabajo + First Artboard Primera mesa de trabajo + Previous Artboard Mesa de trabajo anterior + Next Artboard Mesa de trabajo siguiente + Last Artboard Última mesa de trabajo Go To Link Ir al vínculo GPL palettes keep solid colours only, as RGB. Las paletas GPL solo conservan colores sólidos, en RGB. GPU Performance Rendimiento de GPU @@ -4351,6 +4356,7 @@ axis Both Ambos Reset all Restablecer todo Reset to default Restablecer valor predeterminado Reset all Revolve settings to their defaults. Restablece todos los ajustes de revolución a sus valores predeterminados. + Step up or down Subir o bajar un paso Drag values or labels. Double-click a value to type. Arrastra los valores o las etiquetas. Haz doble clic en un valor para escribir. Drag rings to rotate. Shift snaps to 15°. Arrastra los anillos para girar. Shift ajusta a pasos de 15°. Drag the light, or its rings. Shift snaps to 15°. Arrastra la luz o sus anillos. Shift ajusta a pasos de 15°. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index a0fd4b8c1..c357563e5 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -789,6 +789,7 @@ Reset all Palauta kaikki Reset to default Palauta oletus Reset all Revolve settings to their defaults. Palauta kaikki pyöräytysasetukset oletusarvoihin. + Step up or down Askel ylös tai alas Drag values or labels. Double-click a value to type. Vedä arvoja tai nimiöitä. Kirjoita arvo kaksoisnapsauttamalla. Drag rings to rotate. Shift snaps to 15°. Kierrä vetämällä renkaita. Vaihto kohdistaa 15°:n välein. Drag the light, or its rings. Shift snaps to 15°. Vedä valoa tai sen renkaita. Vaihto kohdistaa 15°:n välein. @@ -834,6 +835,10 @@ lighting Light Valo Export Keyboard Shortcuts… Vie pikanäppäimet… File Info Dialog Tiedoston tiedot -valintaikkuna Go to Artboard Siirry piirtoalustaan + First Artboard Ensimmäinen piirtoalusta + Previous Artboard Edellinen piirtoalusta + Next Artboard Seuraava piirtoalusta + Last Artboard Viimeinen piirtoalusta Graphic Style Options… Grafiikkatyylin asetukset… Symbol Options Symbolin asetukset Symbol Options… Symbolin asetukset… @@ -1896,6 +1901,7 @@ lighting Light Valo Variation Vaihtelu Color Models Värijärjestelmät Color Picker Värinvalitsin + Color wheel Väriympyrä Color Swatches Värimallit Only Web Colors Vain verkkovärit Original: click to restore Alkuperäinen: palauta napsauttamalla diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 344b340dc..e74016c88 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -508,6 +508,7 @@ Color Model: Modèle colorimétrique : Color Models Modèles colorimétriques Color Picker Sélecteur de couleurs + Color wheel Roue chromatique Color Picker… Sélecteur de couleurs… Color Reduction: Réduction des couleurs : Color Settings Paramètres de couleur @@ -1199,6 +1200,10 @@ glyph {id} glyphe {id} Glyphs Glyphes Go to Artboard Atteindre le plan de travail + First Artboard Premier plan de travail + Previous Artboard Plan de travail précédent + Next Artboard Plan de travail suivant + Last Artboard Dernier plan de travail Go To Link Atteindre le lien GPL palettes keep solid colours only, as RGB. Les palettes GPL ne conservent que les couleurs unies, en RVB. GPU Performance Performances GPU @@ -4361,6 +4366,7 @@ effect Soft Doux Reset all Tout réinitialiser Reset to default Valeur par défaut Reset all Revolve settings to their defaults. Rétablir les valeurs par défaut de tous les réglages de révolution. + Step up or down Augmenter ou diminuer d’un cran Drag values or labels. Double-click a value to type. Faites glisser les valeurs ou les libellés. Double-cliquez sur une valeur pour la saisir. Drag rings to rotate. Shift snaps to 15°. Faites glisser les anneaux pour tourner. Maj contraint par pas de 15°. Drag the light, or its rings. Shift snaps to 15°. Faites glisser la lumière ou ses anneaux. Maj contraint par pas de 15°. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index cf747808f..8e28c70a6 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -679,6 +679,7 @@ Color Model: Színmodell: Color Models Színmodellek Color Picker Színválasztó + Color wheel Színkör Color Picker… Színválasztó… Color Reduction: Színcsökkentés: Color Settings Színbeállítások @@ -1075,6 +1076,7 @@ Drag to move Húzással áthelyezhető Drag to move out of the set Húzással kivehető a csoportból Drag to the window's left edge to dock Dokkoláshoz húzd az ablak bal szélére + Step up or down Léptetés fel vagy le Drag values or labels. Double-click a value to type. Húzd az értékeket vagy a címkéket. Gépeléshez kattints duplán egy értékre. Draw Rajzolás Draw Behind Rajzolás mögé @@ -1502,6 +1504,10 @@ glyph {id} glifa {id} Glyphs Glifák Go to Artboard Ugrás rajztáblára + First Artboard Első rajztábla + Previous Artboard Előző rajztábla + Next Artboard Következő rajztábla + Last Artboard Utolsó rajztábla Go to Conflict Ugrás az ütközésre Go To Link Ugrás a csatoláshoz Good Jó diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index fe5f1a4dc..e781a57e6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -507,6 +507,7 @@ Color Model: Modello colore: Color Models Modelli colore Color Picker Selettore colore + Color wheel Ruota dei colori Color Picker… Selettore colore… Color Reduction: Riduzione colori: Color Settings Impostazioni colore @@ -1199,6 +1200,10 @@ glyph {id} glifo {id} Glyphs Glifi Go to Artboard Vai alla tavola da disegno + First Artboard Prima tavola da disegno + Previous Artboard Tavola da disegno precedente + Next Artboard Tavola da disegno successiva + Last Artboard Ultima tavola da disegno Go To Link Vai al collegamento GPL palettes keep solid colours only, as RGB. Le palette GPL conservano solo i colori pieni, in RGB. GPU Performance Prestazioni GPU @@ -4356,6 +4361,7 @@ effect Regular Normale Reset all Ripristina tutto Reset to default Ripristina valore predefinito Reset all Revolve settings to their defaults. Ripristina tutte le impostazioni di rivoluzione ai valori predefiniti. + Step up or down Aumenta o riduci di un passo Drag values or labels. Double-click a value to type. Trascina i valori o le etichette. Fai doppio clic su un valore per digitarlo. Drag rings to rotate. Shift snaps to 15°. Trascina gli anelli per ruotare. Shift usa incrementi di 15°. Drag the light, or its rings. Shift snaps to 15°. Trascina la luce o i suoi anelli. Shift usa incrementi di 15°. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index af96e8078..e925da051 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1051,6 +1051,7 @@ Color Model: カラーモデル: Color Models カラーモデル Color Picker カラーピッカー + Color wheel カラーホイール Color Picker… カラーピッカー… Color Reduction: カラー減色: Color Settings カラー設定 @@ -1513,6 +1514,10 @@ Global Process Color グローバルプロセスカラー Go To Link リンク元へ移動 Go to Artboard アートボードへ移動 + First Artboard 最初のアートボード + Previous Artboard 前のアートボード + Next Artboard 次のアートボード + Last Artboard 最後のアートボード Gradient Fill: click to apply, drag onto art グラデーションの塗り: クリックで適用、アートにドラッグ Gradient Mesh グラデーションメッシュ Gradient Tool グラデーションツール @@ -4396,6 +4401,7 @@ effect Vertical 垂直 Reset all すべてリセット Reset to default 初期値に戻す Reset all Revolve settings to their defaults. 回転体のすべての設定を初期値に戻します。 + Step up or down 1 段階ずつ増減 Drag values or labels. Double-click a value to type. 数値やラベルをドラッグして調整。数値をダブルクリックすると入力できます。 Drag rings to rotate. Shift snaps to 15°. リングをドラッグして回転。Shiftで15°刻みに調整。 Drag the light, or its rings. Shift snaps to 15°. ライトやリングをドラッグ。Shiftで15°刻みに調整。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4268d7378..c22818da9 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1046,6 +1046,7 @@ Color Model: 색상 모델: Color Models 색상 모델 Color Picker 색상 피커 + Color wheel 색상환 Color Picker… 색상 피커… Color Reduction: 색상 감소: Color Settings 색상 설정 @@ -1508,6 +1509,10 @@ Global Process Color 전역 프로세스 색상 Go To Link 링크로 이동 Go to Artboard 아트보드로 이동 + First Artboard 첫 아트보드 + Previous Artboard 이전 아트보드 + Next Artboard 다음 아트보드 + Last Artboard 마지막 아트보드 Gradient Fill: click to apply, drag onto art 그레이디언트 칠: 클릭하여 적용, 아트웍에 끌어 놓기 Gradient Mesh 그레이디언트 메시 Gradient Tool 그레이디언트 도구 @@ -4322,6 +4327,7 @@ effect Vertical 세로 Reset all 모두 재설정 Reset to default 기본값으로 재설정 Reset all Revolve settings to their defaults. 모든 회전체 설정을 기본값으로 재설정합니다. + Step up or down 한 단계 올리기 또는 내리기 Drag values or labels. Double-click a value to type. 값이나 레이블을 드래그하세요. 값을 더블클릭하면 입력할 수 있습니다. Drag rings to rotate. Shift snaps to 15°. 링을 드래그해 회전하세요. Shift를 누르면 15°씩 스냅됩니다. Drag the light, or its rings. Shift snaps to 15°. 조명이나 링을 드래그하세요. Shift를 누르면 15°씩 스냅됩니다. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ba1a49344..42801725d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -747,6 +747,7 @@ Reset all Resetuj wszystko Reset to default Przywróć domyślne Reset all Revolve settings to their defaults. Przywróć domyślne wartości wszystkich ustawień obrotu. + Step up or down Zwiększ lub zmniejsz o krok Drag values or labels. Double-click a value to type. Przeciągaj wartości lub etykiety. Kliknij dwukrotnie wartość, aby ją wpisać. Drag rings to rotate. Shift snaps to 15°. Przeciągaj pierścienie, aby obracać. Shift przyciąga co 15°. Drag the light, or its rings. Shift snaps to 15°. Przeciągaj światło lub jego pierścienie. Shift przyciąga co 15°. @@ -792,6 +793,10 @@ lighting Light Światło Export Keyboard Shortcuts… Eksportuj skróty klawiaturowe… File Info Dialog Okno informacji o pliku Go to Artboard Przejdź do obszaru kompozycji + First Artboard Pierwszy obszar kompozycji + Previous Artboard Poprzedni obszar kompozycji + Next Artboard Następny obszar kompozycji + Last Artboard Ostatni obszar kompozycji Graphic Style Options… Opcje stylu graficznego… Symbol Options Opcje symbolu Symbol Options… Opcje symbolu… @@ -1843,6 +1848,7 @@ lighting Light Światło Variation: Odchylenie: Color Models Modele kolorów Color Picker Próbnik kolorów + Color wheel Koło kolorów Color Swatches Próbki kolorów Only Web Colors Tylko kolory WWW Original: click to restore Oryginał: kliknij, aby przywrócić diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 32bf7d118..ea126556b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -463,6 +463,7 @@ Color Model: Modelo de Cor: Color Models Modelos de Cor Color Picker Seletor de Cores + Color wheel Roda de cores Color Picker… Seletor de Cores… Color Reduction: Redução de Cores: Color Settings Configurações de Cor @@ -1055,6 +1056,10 @@ Glyphs Glifos Go To Link Ir para o Link Go to Artboard Ir para Mesa de Trabalho + First Artboard Primeira mesa de trabalho + Previous Artboard Mesa de trabalho anterior + Next Artboard Próxima mesa de trabalho + Last Artboard Última mesa de trabalho Gradient Gradiente Gradient Fill: click to apply, drag onto art Preenchimento de Gradiente: clique para aplicar, arraste sobre a arte Gradient Finishes Finalizações de Gradiente @@ -2923,6 +2928,7 @@ imageTrace Outline Contorno Reset all Redefinir tudo Reset to default Restaurar padrão Reset all Revolve settings to their defaults. Restaurar os valores padrão de todas as configurações de revolução. + Step up or down Aumentar ou diminuir um passo Drag values or labels. Double-click a value to type. Arraste os valores ou rótulos. Clique duas vezes em um valor para digitá-lo. Drag rings to rotate. Shift snaps to 15°. Arraste os anéis para girar. Shift ajusta em incrementos de 15°. Drag the light, or its rings. Shift snaps to 15°. Arraste a luz ou seus anéis. Shift ajusta em incrementos de 15°. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8d1287235..c9c9a4055 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -505,6 +505,7 @@ Color Model: Цветовая модель: Color Models Цветовые модели Color Picker Палитра цветов + Color wheel Цветовой круг Color Picker… Палитра цветов… Color Reduction: Сокращение числа цветов: Color Settings Настройки цвета @@ -1197,6 +1198,10 @@ glyph {id} глиф {id} Glyphs Глифы Go to Artboard Перейти к монтажной области + First Artboard Первая монтажная область + Previous Artboard Предыдущая монтажная область + Next Artboard Следующая монтажная область + Last Artboard Последняя монтажная область Go To Link Перейти по ссылке GPL palettes keep solid colours only, as RGB. Палитры GPL сохраняют только сплошные цвета в RGB. GPU Performance Производительность GPU @@ -4352,6 +4357,7 @@ axis Both Оба Reset all Сбросить всё Reset to default Вернуть значение по умолчанию Reset all Revolve settings to their defaults. Возвращает все настройки вращения профиля к значениям по умолчанию. + Step up or down Шаг вверх или вниз Drag values or labels. Double-click a value to type. Перетаскивайте значения или подписи. Для ввода дважды щёлкните значение. Drag rings to rotate. Shift snaps to 15°. Перетаскивайте кольца для вращения. Shift задаёт шаг 15°. Drag the light, or its rings. Shift snaps to 15°. Перетаскивайте источник света или кольца. Shift задаёт шаг 15°. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 713a977d0..f182c945a 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -494,6 +494,7 @@ Color Model: Färgmodell: Color Models Färgmodeller Color Picker Färgväljare + Color wheel Färghjul Color Picker… Färgväljare… Color Reduction: Färgreducering: Color Settings Färginställningar @@ -1177,6 +1178,10 @@ glyph {id} glyf {id} Glyphs Glyfer Go to Artboard Gå till rityta + First Artboard Första ritytan + Previous Artboard Föregående rityta + Next Artboard Nästa rityta + Last Artboard Sista ritytan Go To Link Gå till länk GPL palettes keep solid colours only, as RGB. GPL-paletter sparar bara enfärgade färger, som RGB. GPU Performance GPU-prestanda @@ -4283,6 +4288,7 @@ effect Soft Mjuk Reset all Återställ alla Reset to default Återställ till standard Reset all Revolve settings to their defaults. Återställ alla inställningar för Rotera runt axel till standardvärdena. + Step up or down Ett steg upp eller ned Drag values or labels. Double-click a value to type. Dra i värden eller etiketter. Dubbelklicka på ett värde för att skriva. Drag rings to rotate. Shift snaps to 15°. Dra i ringarna för att rotera. Shift fäster i steg om 15°. Drag the light, or its rings. Shift snaps to 15°. Dra i ljuset eller dess ringar. Shift fäster i steg om 15°. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5d64483ec..a5299733a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -501,6 +501,7 @@ Color Model: Колірна модель: Color Models Колірні моделі Color Picker Палітра кольорів + Color wheel Колірне коло Color Picker… Палітра кольорів… Color Reduction: Скорочення кількості кольорів: Color Settings Налаштування кольору @@ -1193,6 +1194,10 @@ glyph {id} гліф {id} Glyphs Гліфи Go to Artboard Перейти до монтажної області + First Artboard Перша монтажна область + Previous Artboard Попередня монтажна область + Next Artboard Наступна монтажна область + Last Artboard Остання монтажна область Go To Link Перейти за посиланням GPL palettes keep solid colours only, as RGB. Палітри GPL зберігають лише суцільні кольори RGB. GPU Performance Продуктивність GPU @@ -4336,6 +4341,7 @@ axis Both Обидві Reset all Скинути все Reset to default Відновити типове значення Reset all Revolve settings to their defaults. Відновити типові значення всіх параметрів обертання. + Step up or down Крок угору або вниз Drag values or labels. Double-click a value to type. Перетягуйте значення або підписи. Двічі клацніть значення, щоб увести його. Drag rings to rotate. Shift snaps to 15°. Перетягуйте кільця для обертання. Shift задає крок 15°. Drag the light, or its rings. Shift snaps to 15°. Перетягуйте джерело світла або його кільця. Shift задає крок 15°. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index dd552e948..149cb4702 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -592,6 +592,7 @@ Color Model: Mô hình màu: Color Models Các mô hình màu Color Picker Bộ chọn màu + Color wheel Bánh xe màu Color Picker… Bộ chọn màu… Color Reduction: Giảm màu: Color Settings Cài đặt màu @@ -960,6 +961,7 @@ Drag to move Kéo để di chuyển Drag to move out of the set Kéo để tách khỏi bộ Drag to the window's left edge to dock Kéo đến cạnh trái cửa sổ để neo + Step up or down Tăng hoặc giảm một bước Drag values or labels. Double-click a value to type. Kéo giá trị hoặc nhãn. Bấm đúp vào giá trị để nhập. Draw Vẽ Draw Behind Vẽ phía sau @@ -1377,6 +1379,10 @@ glyph {id} Glyph {id} Glyphs Glyph Go to Artboard Đến bảng vẽ + First Artboard Bảng vẽ đầu tiên + Previous Artboard Bảng vẽ trước + Next Artboard Bảng vẽ tiếp theo + Last Artboard Bảng vẽ cuối cùng Go To Link Đến liên kết Good Tốt GPL palettes keep solid colours only, as RGB. Bảng màu GPL chỉ lưu màu đặc theo RGB. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 251768913..c1e8299d3 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -462,6 +462,7 @@ Color Model: 颜色模型: Color Models 颜色模型 Color Picker 拾色器 + Color wheel 色轮 Color Picker… 颜色选择器… Color Reduction: 色彩缩减: Color Settings 颜色设置 @@ -1146,6 +1147,10 @@ glyph {id} 字符 {id} Glyphs 字形 Go to Artboard 前往画板 + First Artboard 第一个画板 + Previous Artboard 上一个画板 + Next Artboard 下一个画板 + Last Artboard 最后一个画板 Go To Link 转到链接 GPL palettes keep solid colours only, as RGB. GPL 调板仅保留为 RGB 的纯色。 GPU Performance GPU 性能 @@ -3930,6 +3935,7 @@ effect Vertical 垂直 Reset all 全部重置 Reset to default 重置为默认值 Reset all Revolve settings to their defaults. 将所有旋转成形设置重置为默认值。 + Step up or down 逐级增减 Drag values or labels. Double-click a value to type. 拖动数值或标签。双击数值即可输入。 Drag rings to rotate. Shift snaps to 15°. 拖动圆环旋转。按住 Shift 以 15° 为步长调整。 Drag the light, or its rings. Shift snaps to 15°. 拖动光源或圆环。按住 Shift 以 15° 为步长调整。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 23bdde637..696f6fc26 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -476,6 +476,7 @@ Color Model: 色彩模型: Color Models 色彩模型 Color Picker 顏色選擇器 + Color wheel 色輪 Color Picker… 顏色選擇器… Color Reduction: 色彩縮減: Color Settings 色彩設定 @@ -1170,6 +1171,10 @@ glyph {id} 字符 {id} Glyphs 字符 Go to Artboard 前往工作區域 + First Artboard 第一個工作區域 + Previous Artboard 上一個工作區域 + Next Artboard 下一個工作區域 + Last Artboard 最後一個工作區域 Go To Link 前往連結 GPL palettes keep solid colours only, as RGB. GPL 調色盤僅保留純色,並以 RGB 儲存。 GPU Performance GPU 效能 @@ -3714,6 +3719,7 @@ effect Soft 柔和 Reset all 全部重設 Reset to default 重設為預設值 Reset all Revolve settings to their defaults. 將所有旋轉成形設定重設為預設值。 + Step up or down 逐級增減 Drag values or labels. Double-click a value to type. 拖曳數值或標籤。按兩下數值即可輸入。 Drag rings to rotate. Shift snaps to 15°. 拖曳圓環旋轉。按住 Shift 以 15° 為間距調整。 Drag the light, or its rings. Shift snaps to 15°. 拖曳光源或圓環。按住 Shift 以 15° 為間距調整。 diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 2902fbad9..c675bce54 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -250,9 +250,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { #[cfg(test)] mod tests { + use egui::accesskit::{Role, Toggled}; + use super::super::tests_appearance::{click, frame_events, text_rect}; use super::*; use crate::toolbar::tests::widget_rects; + use crate::widgets::accessibility_tests::{role_and_state, tree}; #[test] fn single_object_aligns_to_artboard() { @@ -466,6 +469,20 @@ mod tests { assert_eq!(align_to(&app), AlignTo::Selection, "the stored choice"); } + /// Screen readers name the align buttons, with no pressed state, and the Align To buttons, the + /// chosen one pressed. + #[test] + fn screen_readers_name_the_buttons_and_the_align_to_choice() { + let (mut app, _, _) = back_and_front(); + let tree = tree(|ui| show(&mut app, ui)); + for (_, label, _, _) in ALIGN.iter().chain(DISTRIBUTE.iter()) { + assert_eq!(role_and_state(&tree, label), (Role::Button, None), "{label}"); + } + assert_eq!(role_and_state(&tree, "Align to Selection"), (Role::Button, Some(Toggled::True))); + assert_eq!(role_and_state(&tree, "Align to Key Object"), (Role::Button, None)); + assert_eq!(role_and_state(&tree, "Align to Artboard"), (Role::Button, None)); + } + /// #789: the Control bar's and the Properties panel's align buttons follow Align To too. #[test] fn the_other_align_buttons_align_to_the_align_to_choice() { diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index a6b5a9d62..2d3d94b2b 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -761,7 +761,7 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: } match v { Value::Bool(b) => { - if widgets::check(ui, "", *b, true) { + if widgets::check_box(ui, label, *b, true) { change = Some((key.clone(), json!(!b))); } } diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 58b915f4b..61349e934 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -498,7 +498,7 @@ fn tint_rows(ui: &mut Ui, tint: &Tint, slider_w: f32, field_w: f32) -> Option<(f ui.horizontal(|ui| { ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new("T").size(12.5).color(t.text))); let track = |x: f32| to32(&tint.base.tinted(x)); - let (nv, phase) = widgets::color_slider(ui, ("color-slider", "tint"), tint.tint, slider_w, &track); + let (nv, phase) = widgets::color_slider_named(ui, ("color-slider", "tint"), "T", tint.tint, slider_w, &track); let field = widgets::plain_field(ui, ("color-field", "tint"), (tint.tint * 100.0).round() as f64, "%", 0, field_w); match (nv, field) { (Some(v), _) => Some(((v * 100.0).round() / 100.0, phase)), @@ -613,9 +613,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let track = |x: f32| to32(&track_color(mode, &comps, i, x)); let grey = |_x: f32| t.input; let (nv, phase) = if enabled { - widgets::color_slider(ui, ("color-slider", i), mode.unit(i, v), slider_w, &track) + widgets::color_slider_named(ui, ("color-slider", i), lbl, mode.unit(i, v), slider_w, &track) } else { - widgets::color_slider(ui, ("color-slider", i), 0.0, slider_w, &grey) + widgets::color_slider_named(ui, ("color-slider", i), lbl, 0.0, slider_w, &grey) }; if let Some(nv) = nv && enabled diff --git a/crates/ui-egui/src/panels/color_guide.rs b/crates/ui-egui/src/panels/color_guide.rs index c9c8a478c..8de5ff1eb 100644 --- a/crates/ui-egui/src/panels/color_guide.rs +++ b/crates/ui-egui/src/panels/color_guide.rs @@ -216,7 +216,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { None => tl!("Limit colors to swatch library").into(), }; // The tip is translated around the library's name, not looked up whole. - let lr = widgets::icon_button(ui, "library", "", limit.is_some(), 24.0).on_hover_text(tip); + let lr = widgets::icon_button(ui, "library", "", limit.is_some(), 24.0); + lr.widget_info(|| widgets::icon_button_info(ui.is_enabled(), limit.is_some(), &tip)); + let lr = lr.on_hover_text(tip); egui::Popup::menu(&lr).show(|ui| limit_menu(app, ui)); ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0)); // Recolor Artwork with the harmony colours as the new colours: the selected art's, or @@ -278,6 +280,20 @@ mod tests { out.textures_delta.clear(); } + /// Screen readers name the library button by its tool tip, pressed while a library limits the + /// colors. + #[test] + fn screen_readers_name_the_limit_button() { + use crate::widgets::accessibility_tests::{role_and_state, tree}; + use egui::accesskit::{Role, Toggled}; + let mut app = app(); + let shown = tree(|ui| show(&mut app, ui)); + assert_eq!(role_and_state(&shown, "Limit colors to swatch library"), (Role::Button, None)); + app.run("ui.colorGuideLimit", json!({"library": "Earth Tones"})).unwrap(); + let shown = tree(|ui| show(&mut app, ui)); + assert_eq!(role_and_state(&shown, "Limit colors to swatch library: Earth Tones"), (Role::Button, Some(Toggled::True))); + } + #[test] fn base_stays_until_set_and_the_grid_follows_the_options() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/color_themes.rs b/crates/ui-egui/src/panels/color_themes.rs index 751f8dfd8..acc2fa8bb 100644 --- a/crates/ui-egui/src/panels/color_themes.rs +++ b/crates/ui-egui/src/panels/color_themes.rs @@ -212,7 +212,9 @@ fn create(app: &mut VectorcraftApp, ui: &mut Ui) { None => tl!("Save theme to My Themes").into(), }; // The tip is translated around the theme's name, not looked up whole. - if widgets::icon_button(ui, "save", "", false, 24.0).on_hover_text(tip).clicked() { + let resp = widgets::icon_button(ui, "save", "", false, 24.0); + resp.widget_info(|| widgets::icon_button_info(ui.is_enabled(), false, &tip)); + if resp.on_hover_text(tip).clicked() { save(app, &ctx); } let doc = app.session.active().is_some(); @@ -368,6 +370,15 @@ mod tests { &app.session.prefs.color_themes } + /// Screen readers name the Save button by its tool tip. + #[test] + fn screen_readers_name_the_save_button() { + use crate::widgets::accessibility_tests::{role_and_state, tree}; + let mut app = app(); + let shown = tree(|ui| show(&mut app, ui)); + assert_eq!(role_and_state(&shown, "Save theme to My Themes"), (egui::accesskit::Role::Button, None)); + } + #[test] fn create_save_edit_and_resave_a_theme() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index 32e667881..ae02d1cbf 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -174,11 +174,10 @@ pub(crate) const TRACE_BUTTON_W: f32 = 104.0; /// The Image Trace button for a selected image (Control bar, Properties): a click traces it with /// the Default preset, its arrow lists the presets to trace it with. pub(crate) fn trace_button(app: &mut VectorcraftApp, ui: &mut Ui, width: f32) { - let (main, arrow) = widgets::split_button(ui, tl!("Image Trace"), width); + let (main, arrow) = widgets::split_button_named(ui, tl!("Image Trace"), tl!("Tracing Presets"), width); if main.on_hover_text(tl!("Trace the image with the Default preset")).clicked() { trace_with(app, "Default"); } - let arrow = arrow.on_hover_text(tl!("Tracing Presets")); if let Some(p) = egui::Popup::menu(&arrow).show(|ui| preset_list(app, ui, "")).and_then(|r| r.inner) { trace_with(app, &p); } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index e76596486..984b8385c 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -385,13 +385,13 @@ pub(crate) fn paint_chip(app: &mut VectorcraftApp, ui: &mut Ui, stroke: bool, si let (fill, stroke_paint) = current_paints(app); let mixed = mixed_paints(app, ui.ctx()); let (paint, mixed) = if stroke { (stroke_paint, mixed.1) } else { (fill, mixed.0) }; - let resp = crate::widgets::chip_button(ui, size, chevron, |ui, chip| { - crate::widgets::proxy_chip(ui, chip, &paint, mixed, stroke.then_some(size * 0.25)); - }); - let resp = resp.on_hover_text(if stroke { - "Stroke: click for swatches, Shift-click for the color mixer" + let tip = if stroke { + tl!("Stroke: click for swatches, Shift-click for the color mixer") } else { - "Fill: click for swatches, Shift-click for the color mixer" + tl!("Fill: click for swatches, Shift-click for the color mixer") + }; + let resp = crate::widgets::chip_button_named(ui, size, chevron, tip, |ui, chip| { + crate::widgets::proxy_chip(ui, chip, &paint, mixed, stroke.then_some(size * 0.25)); }); if resp.clicked() { app.run("paint.toggleActive", json!({ "fill": !stroke })).ok(); diff --git a/crates/ui-egui/src/panels/navigator.rs b/crates/ui-egui/src/panels/navigator.rs index 4af13e7a4..fa56be3cf 100644 --- a/crates/ui-egui/src/panels/navigator.rs +++ b/crates/ui-egui/src/panels/navigator.rs @@ -263,7 +263,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } let sw = (ui.available_width() - 30.0).max(40.0); let track = |_: f32| t.input_border; - if let (Some(v), phase) = widgets::color_slider(ui, "nav-slider", zoom_to_slider(zoom) as f32, sw, &track) + if let (Some(v), phase) = widgets::color_slider_named(ui, "nav-slider", tl!("Zoom"), zoom_to_slider(zoom) as f32, sw, &track) && phase != widgets::Live::Idle { z = Some(slider_to_zoom(v as f64)); diff --git a/crates/ui-egui/src/panels/pathfinder.rs b/crates/ui-egui/src/panels/pathfinder.rs index 425772367..440b4053b 100644 --- a/crates/ui-egui/src/panels/pathfinder.rs +++ b/crates/ui-egui/src/panels/pathfinder.rs @@ -100,6 +100,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { #[cfg(test)] mod tests { use super::*; + use crate::widgets::accessibility_tests::{named, tree}; use vectorcraft_engine::doc::{NodeId, NodeKind}; /// #1635: a stroked object expanded inside a group leaves a group holding one group (its fill @@ -125,4 +126,20 @@ mod tests { let b = united.geometric_bounds().unwrap(); assert_eq!((b.x0, b.y0, b.x1, b.y1), (5.0, 5.0, 65.0, 65.0), "the stroke's outer edge"); } + + /// Screen readers name each Shape Mode and Pathfinder button, enabled for two selected objects. + #[test] + fn screen_readers_name_the_buttons() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone(); + let b = app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let tree = tree(|ui| show(&mut app, ui)); + for (_, label, _) in SHAPE_MODES.into_iter().chain(PATHFINDERS) { + let node = named(&tree, label); + assert_eq!(node.role(), egui::accesskit::Role::Button, "{label}"); + assert!(!node.is_disabled(), "{label}"); + } + } } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index e46b86498..4c9235cb1 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -161,6 +161,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option widgets::gradient_chip(ui, r, &app.session.last_gradient), _ => widgets::paint_chip(ui, r, &Paint::None), } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(tip))); if resp.on_hover_text(tl!(tip)).clicked() { clicked = Some(cmd); } @@ -600,6 +604,7 @@ fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating let x = egui::Rect::from_min_size(bar.min, vec2(BAR, CLOSE)); let xr = ui.interact(x, egui::Id::new(("tool-flyout-close", key)), Sense::click()); icons::paint(ui, "x", x.shrink2(vec2(1.0, 3.0)), if xr.hovered() { t.text_strong } else { t.text }); + xr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Put back in the toolbar"))); out.close = xr.on_hover_text(tl!("Put back in the toolbar")).clicked(); egui::Rect::from_min_max(bar.min + vec2(0.0, CLOSE), bar.max) } else { @@ -611,11 +616,12 @@ fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating let x = grip.center().x - 2.0 + k as f32 * 2.0; ui.painter().line_segment([pos2(x, grip.center().y - half), pos2(x, grip.center().y + half)], Stroke::new(0.6, t.text_disabled)); } + let tip = if floating { tl!("Drag to move") } else { tl!("Drag to float as a toolbar") }; + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tip)); let resp = if resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); resp } else { - let tip = if floating { tl!("Drag to move") } else { tl!("Drag to float as a toolbar") }; resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tip) }; out.bar = Some(resp); @@ -647,6 +653,7 @@ fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option< if let Some(sc) = shortcut { ui.painter().galley(pos2(r.right() - ROW_END - sc.size().x, r.center().y - sc.size().y / 2.0), sc, color); } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), is_active, tip(tool))); if resp.clicked() { chosen = Some(tool.id); } @@ -700,6 +707,7 @@ fn tool_strip(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option } let ir = egui::Rect::from_center_size(r.center(), vec2(18.0, 18.0)); icons::paint(ui, icons::tool_icon(tool.icon), ir, if active == tool.id { t.text_strong } else { t.icon }); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), active == tool.id, tip(tool))); if resp.on_hover_text(tip(tool)).clicked() { chosen = Some(tool.id); } @@ -840,10 +848,12 @@ fn puppet_warp_options(app: &mut VectorcraftApp, ui: &mut Ui) { #[cfg(test)] pub(crate) mod tests { + use egui::accesskit::{Role, Toggled}; use egui::{Event, PointerButton, Pos2}; use vectorcraft_engine::Session; use super::*; + use crate::widgets::accessibility_tests::{role_and_state, tree}; /// One headless frame of the toolbar; returns the tool buttons' rects, top to bottom. fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec) -> Vec { @@ -929,6 +939,22 @@ pub(crate) mod tests { } } + /// Screen readers name each tool button by its tool tip and say which tool is active, and name + /// the title bar's column toggle and grip and the paint buttons under the tools. + #[test] + fn screen_readers_name_the_tool_buttons_and_the_active_tool() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.select_tool("pen"); + let tree = tree(|ui| show(&mut app, ui)); + let name = |id| tool_info(id).map(tip).unwrap_or_default(); + assert_eq!(role_and_state(&tree, &name("pen")), (Role::Button, Some(Toggled::True))); + assert_eq!(role_and_state(&tree, &name("selection")), (Role::Button, Some(Toggled::False))); + for label in ["Toggle single/double column", "Drag to float the toolbar", "Color (,)", "Gradient (.)", "None (/)"] { + assert_eq!(role_and_state(&tree, label), (Role::Button, None), "{label}"); + } + } + /// #812: double-clicking the Artboard tool opens Artboard Options of the active artboard, a /// graph tool Graph Type for the selected graph, and the Magic Wand tool its panel. #[test] @@ -1564,4 +1590,31 @@ pub(crate) mod tests { app.session.prefs.show_tool_tips = true; assert!(hovered_texts(&mut app, &ctx, 30.0, star).contains(&tip), "on again"); } + + /// Screen readers name a flyout's rows and a floating strip's buttons by their tools' tool tips, + /// with the active tool's pressed, and name the strip's close box and both tear-off bars. + #[test] + fn screen_readers_name_a_flyouts_rows_and_a_floating_strips_buttons() { + let name = |id| tool_info(id).map(tip).unwrap_or_default(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let group: Vec = ["rectangle", "roundedRectangle", "ellipse"].map(String::from).to_vec(); + let flyout = tree(|ui| { + app.ui.flyout = Some(0); + ui.data_mut(|d| d.insert_temp(egui::Id::new("flyout-tools"), group.clone())); + show(&mut app, ui); + }); + assert_eq!(role_and_state(&flyout, &name("roundedRectangle")), (Role::Button, Some(Toggled::False))); + assert_eq!(role_and_state(&flyout, &name("ellipse")), (Role::Button, Some(Toggled::False))); + assert_eq!(role_and_state(&flyout, "Drag to float as a toolbar"), (Role::Button, None)); + app.ui.flyout = None; + app.select_tool("star"); + let tools: Vec = ["polygon", "star"].map(String::from).to_vec(); + app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [150.0, 600.0] }); + let strip = tree(|ui| show(&mut app, ui)); + assert_eq!(role_and_state(&strip, &name("polygon")), (Role::Button, Some(Toggled::False))); + assert_eq!(role_and_state(&strip, &name("star")), (Role::Button, Some(Toggled::True))); + assert_eq!(role_and_state(&strip, "Put back in the toolbar"), (Role::Button, None)); + assert_eq!(role_and_state(&strip, "Drag to move"), (Role::Button, None)); + } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 3d0096cd5..0dd7347e8 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -28,6 +28,7 @@ pub fn paint_icon_button(ui: &mut Ui, resp: Response, icon: &str, tip: &str, sel ui.painter().rect_filled(rect, CornerRadius::same(5), bg); let pad = (size * 0.2).round(); icons::paint(ui, icon, rect.shrink(pad), if selected { t.text_strong } else { t.icon }); + resp.widget_info(|| icon_button_info(ui.is_enabled(), selected, tl!(tip))); if !tip.is_empty() { resp.on_hover_text(tl!(tip)) } else { resp } } @@ -39,6 +40,7 @@ pub fn flat_button(ui: &mut Ui, text: &str, width: f32) -> Response { ui.painter().rect_filled(rect, CornerRadius::same(5), t.button); flat_fill(ui, rect, &resp); ui.painter().with_clip_rect(rect).text(rect.center(), egui::Align2::CENTER_CENTER, tl!(text), egui::FontId::proportional(12.5), t.text_strong); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(text))); resp } @@ -48,6 +50,12 @@ pub const SPLIT_ARROW: f32 = 18.0; /// A [`flat_button`] `width` wide whose right end is a menu arrow (Image Trace ▾) → the /// responses of the button and of its arrow. pub fn split_button(ui: &mut Ui, text: &str, width: f32) -> (Response, Response) { + split_button_named(ui, text, "", width) +} + +/// [`split_button`] whose arrow has the tool tip `arrow_tip`, which screen readers also call it +/// (none when empty). +pub fn split_button_named(ui: &mut Ui, text: &str, arrow_tip: &str, width: f32) -> (Response, Response) { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(width, 24.0), Sense::hover()); let rect = rect.shrink2(vec2(0.0, 0.5)); @@ -68,7 +76,12 @@ pub fn split_button(ui: &mut Ui, text: &str, width: f32) -> (Response, Response) t.text_strong, ); icons::paint(ui, "chevron-down", Rect::from_center_size(arrow_rect.center(), vec2(12.0, 12.0)), t.text_dim); - (main, arrow) + main.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(text))); + if arrow_tip.is_empty() { + return (main, arrow); + } + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(arrow_tip))); + (main, arrow.on_hover_text(tl!(arrow_tip))) } /// The pressed or hovered fill of a flat button part. @@ -103,6 +116,7 @@ pub fn primary_button_enabled(ui: &mut Ui, text: &str, enabled: bool) -> Respons ui.painter().rect_stroke(rect, CornerRadius::same(6), egui::Stroke::new(1.0, t.button_border), egui::StrokeKind::Inside); } ui.painter().galley(rect.center() - galley.size() / 2.0, galley, fg); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled && ui.is_enabled(), tl!(text))); resp } @@ -114,6 +128,7 @@ pub fn secondary_button(ui: &mut Ui, text: &str) -> Response { let (rect, resp) = ui.allocate_exact_size(vec2(w.max(72.0), 28.0), Sense::click()); ui.painter().rect_filled(rect, CornerRadius::same(6), if resp.hovered() { t.hover } else { t.button }); ui.painter().galley(rect.center() - galley.size() / 2.0, galley, t.text); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(text))); resp } @@ -657,6 +672,11 @@ pub fn proxy_chip(ui: &Ui, rect: Rect, paint: &Paint, mixed: bool, ring: Option< /// bar's Style picker): `draw` paints the `size`-square chip, which gets a frame, and with /// `chevron` a chevron follows it. pub fn chip_button(ui: &mut Ui, size: f32, chevron: bool, draw: impl FnOnce(&Ui, Rect)) -> Response { + chip_button_named(ui, size, chevron, "", draw) +} + +/// [`chip_button`] with the tool tip `tip`, which screen readers also call it (none when empty). +pub fn chip_button_named(ui: &mut Ui, size: f32, chevron: bool, tip: &str, draw: impl FnOnce(&Ui, Rect)) -> Response { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(if chevron { size + 16.0 } else { size }, size), Sense::click()); let chip = Rect::from_min_size(r.min, vec2(size, size)).shrink(2.0); @@ -665,7 +685,11 @@ pub fn chip_button(ui: &mut Ui, size: f32, chevron: bool, draw: impl FnOnce(&Ui, if chevron { icons::paint(ui, "chevron-down", Rect::from_center_size(pos2(chip.right() + 9.0, r.center().y), vec2(12.0, 12.0)), t.text_dim); } - resp + if tip.is_empty() { + return resp; + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(tip))); + resp.on_hover_text(tl!(tip)) } /// What the user did on a [`fill_stroke_proxy`]. @@ -732,6 +756,13 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool } else { None }; + let on = ui.is_enabled(); + fill_resp + .widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, on, fill_active, tl!("Fill (X), double-click for the Color Picker"))); + stroke_resp + .widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, on, !fill_active, tl!("Stroke (X), double-click for the Color Picker"))); + swap.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tl!("Swap Fill and Stroke (Shift+X)"))); + def.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tl!("Default Fill and Stroke (D)"))); ProxyClicks { fill: fill_resp.on_hover_text(tl!("Fill (X), double-click for the Color Picker")).clicked(), stroke: stroke_resp.on_hover_text(tl!("Stroke (X), double-click for the Color Picker")).clicked(), @@ -834,6 +865,12 @@ fn searchable_combo(ui: &mut Ui, id: egui::Id, current: &str, width: f32, lis ); let height = (galley.size().y.max(icon.y) + 2.0 * pad.y).max(ui.spacing().interact_size.y); let (rect, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click()); + // Screen readers get what egui's combo box gives them: its role and the text it shows. + resp.widget_info(|| egui::WidgetInfo { + enabled: ui.is_enabled(), + current_text_value: Some(current.to_owned()), + ..egui::WidgetInfo::new(egui::WidgetType::ComboBox) + }); if ui.is_rect_visible(rect) { let v = if open { ui.visuals().widgets.open } else { *ui.style().interact(&resp) }; ui.painter().rect(rect.expand(v.expansion), v.corner_radius, v.weak_bg_fill, v.bg_stroke, StrokeKind::Inside); @@ -1027,6 +1064,7 @@ pub fn opacity_blend( let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if enabled { Sense::click() } else { Sense::hover() }); ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside); icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if enabled { t.icon } else { t.text_disabled }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled && ui.is_enabled(), tl!("Opacity"))); egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { let mut o = opacity.unwrap_or(1.0) * 100.0; let r = slider(ui, egui::Slider::new(&mut o, 0.0..=100.0).show_value(false)); @@ -1057,9 +1095,22 @@ pub fn reference_point(ui: &mut Ui, current: usize) -> Option { let line = Stroke::new(1.0, t.text_dim); ui.painter().rect_stroke(Rect::from_two_pos(centre(0), centre(8)), 0.0, line, StrokeKind::Middle); let mut out = None; - for i in 0..9 { + // Screen readers name the squares left to right and top to bottom. + let names = [ + tl!("Top Left"), + tl!("Top"), + tl!("Top Right"), + tl!("Left"), + tl!("Center"), + tl!("Right"), + tl!("Bottom Left"), + tl!("Bottom"), + tl!("Bottom Right"), + ]; + for (i, name) in names.into_iter().enumerate() { let r = Rect::from_center_size(centre(i), Vec2::splat(sq)); let resp = ui.interact(r.expand(2.0), ui.id().with(("refpt", i)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, ui.is_enabled(), i == current, name)); if resp.clicked() { out = Some(i); } @@ -1086,6 +1137,7 @@ pub fn angle_dial(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, angle let a = (angle as f32).to_radians(); ui.painter().line_segment([c, c + vec2(a.cos(), -a.sin()) * (r - 3.0)], Stroke::new(1.5, t.text)); ui.painter().circle_filled(c, 2.0, t.text); + resp.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), angle, tl!("Angle"))); let (p, _) = pointer_phase(&resp)?; let v = p - c; if v.length() < 2.0 { @@ -1107,6 +1159,7 @@ pub fn toggle_icon(ui: &mut Ui, on_icon: &str, on: bool, size: f32, tip: &str) - } else if resp.hovered() { icons::paint(ui, on_icon, rect.shrink(size * 0.2), t.text_disabled); } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), on, tl!(tip))); resp.on_hover_text(tl!(tip)).clicked() } @@ -1121,9 +1174,16 @@ pub fn icon_button_enabled(ui: &mut Ui, icon: &str, tip: &str, selected: bool, e let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); let pad = (size * 0.2).round(); icons::paint(ui, icon, rect.shrink(pad), t.text_disabled); + resp.widget_info(|| icon_button_info(false, selected, tl!(tip))); if tip.is_empty() { resp } else { resp.on_hover_text(tl!(tip)) } } +/// What screen readers get for an icon button: a button called `name` (already translated), +/// reported as pressed while `selected` and with no pressed state otherwise. +pub(crate) fn icon_button_info(enabled: bool, selected: bool, name: &str) -> egui::WidgetInfo { + egui::WidgetInfo { selected: selected.then_some(true), ..egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, name) } +} + /// A slider whose rail shows, filled up to the handle: the theme's widget fill matches the panel /// and dialog background, which leaves only the handle visible (#1483). pub fn slider(ui: &mut Ui, slider: egui::Slider<'_>) -> Response { @@ -1157,6 +1217,7 @@ pub fn link_label(ui: &mut Ui, text: &str) -> Response { ui.painter().line_segment([pos2(x, y), pos2((x + 1.0).min(rect.right()), y)], Stroke::new(1.0, t.text_dim)); x += 2.5; } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(text))); resp } @@ -1202,9 +1263,20 @@ pub fn check_name(ui: &mut Ui, name: &str, value: bool) -> bool { check_row(ui, name, Some(value), true).0 } +/// A [`check`] box without text, for a row whose label is drawn beside it in another column: +/// screen readers call it `name` (already translated). Returns true when toggled. +pub fn check_box(ui: &mut Ui, name: &str, value: bool, enabled: bool) -> bool { + check_row_named(ui, "", name, Some(value), enabled).0 +} + /// [`check3`] with its `text` already translated, also returning the row's response (what a /// tooltip goes on: a widget around it never counts as hovered under the box). fn check_row(ui: &mut Ui, text: &str, value: Option, enabled: bool) -> (bool, Response) { + check_row_named(ui, text, text, value, enabled) +} + +/// [`check_row`] that screen readers call `name` instead of its `text`. +fn check_row_named(ui: &mut Ui, text: &str, name: &str, value: Option, enabled: bool) -> (bool, Response) { let t = Tokens::get(ui.ctx()); let (bx, resp, border) = choice_row(ui, text, enabled); let checked = value == Some(true); @@ -1222,18 +1294,34 @@ fn check_row(ui: &mut Ui, text: &str, value: Option, enabled: bool) -> (bo } Some(false) => {} } + resp.widget_info(|| match value { + Some(on) => egui::WidgetInfo::selected(egui::WidgetType::Checkbox, enabled && ui.is_enabled(), on, name), + None => egui::WidgetInfo::labeled(egui::WidgetType::Checkbox, enabled && ui.is_enabled(), name), + }); (enabled && resp.clicked(), resp) } /// Radio button in the style of [`check`], with a disabled state. Returns true when clicked. pub fn radio(ui: &mut Ui, label: &str, selected: bool, enabled: bool) -> bool { + radio_row(ui, tl!(label), tl!(label), selected, enabled) +} + +/// A [`radio`] button without text, for one followed by a field and the text that names it: +/// screen readers call it `name` (already translated). Returns true when clicked. +pub fn radio_button(ui: &mut Ui, name: &str, selected: bool, enabled: bool) -> bool { + radio_row(ui, "", name, selected, enabled) +} + +/// [`radio`] with its `text` already translated, which screen readers call `name`. +fn radio_row(ui: &mut Ui, text: &str, name: &str, selected: bool, enabled: bool) -> bool { let t = Tokens::get(ui.ctx()); - let (bx, resp, border) = choice_row(ui, tl!(label), enabled); + let (bx, resp, border) = choice_row(ui, text, enabled); let c = bx.center(); ui.painter().circle(c, 6.0, if selected && enabled { t.accent_strong } else { t.input }, Stroke::new(1.0, border)); if selected { ui.painter().circle_filled(c, 2.5, if enabled { Color32::WHITE } else { t.text_disabled }); } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, enabled && ui.is_enabled(), selected, name)); enabled && resp.clicked() } @@ -1348,6 +1436,7 @@ fn spin_generic( ui.painter().line_segment([m + vec2(-3.0, -d), m + vec2(0.0, d)], Stroke::new(1.2, c)); ui.painter().line_segment([m + vec2(0.0, d), m + vec2(3.0, -d)], Stroke::new(1.2, c)); } + sresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tl!("Step up or down"))); // A blank field (values that differ) has nothing to step from. if sresp.clicked() && let Some(p) = sresp.interact_pointer_pos() @@ -1405,6 +1494,19 @@ pub fn color_slider( value: f32, width: f32, track: &dyn Fn(f32) -> Color32, +) -> (Option, Live) { + color_slider_named(ui, id, "", value, width, track) +} + +/// [`color_slider`] that screen readers get as a slider named `name` (already translated) with no +/// value (none when `name` is empty). +pub fn color_slider_named( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug, + name: &str, + value: f32, + width: f32, + track: &dyn Fn(f32) -> Color32, ) -> (Option, Live) { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(width, 22.0), Sense::hover()); @@ -1418,6 +1520,9 @@ pub fn color_slider( let thumb = vec![tip, pos2(x + 5.5, tip.y + 6.0), pos2(x + 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 6.0)]; let fill = if resp.dragged() || resp.hovered() { Color32::WHITE } else { t.icon }; ui.painter().add(egui::Shape::convex_polygon(thumb, fill, Stroke::new(1.0, t.border))); + if !name.is_empty() { + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, ui.is_enabled(), name)); + } match pointer_phase(&resp) { Some((p, phase)) => (Some(((p.x - bar.left()) / bar.width()).clamp(0.0, 1.0)), phase), None => (None, Live::Idle), @@ -1506,6 +1611,19 @@ pub fn channel_slider( value: f32, size: Vec2, track: &dyn Fn(f32) -> Color32, +) -> (Option, Live) { + channel_slider_named(ui, id, "", value, size, track) +} + +/// [`channel_slider`] that screen readers get as a slider named `name` (already translated) with +/// no value, as for [`color_slider_named`] (none when `name` is empty). +pub fn channel_slider_named( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug, + name: &str, + value: f32, + size: Vec2, + track: &dyn Fn(f32) -> Color32, ) -> (Option, Live) { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); @@ -1519,6 +1637,9 @@ pub fn channel_slider( let tip = pos2(x + d * 5.0, y); ui.painter().add(egui::Shape::convex_polygon(vec![tip, pos2(x, y + 4.0 * d), pos2(x, y - 4.0 * d)], fill, Stroke::NONE)); } + if !name.is_empty() { + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, ui.is_enabled(), name)); + } match pointer_phase(&resp) { Some((p, phase)) => (Some(((bar.bottom() - p.y) / bar.height()).clamp(0.0, 1.0)), phase), None => (None, Live::Idle), @@ -1881,13 +2002,15 @@ pub fn harmony_wheel(ui: &mut Ui, id: &str, size: f32, colors: &[Color], base: O let h = (-dv.y).atan2(dv.x).to_degrees().rem_euclid(360.0); out.moved = Some((i, [h, (dv.length() / r).clamp(0.0, 1.0), old.to_hsb()[2]])); } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, ui.is_enabled(), tl!("Color wheel"))); }); ui.add_space(6.0); ui.horizontal(|ui| { ui.add_space(((ui.available_width() - size - 76.0) / 2.0).max(0.0)); ui.add_sized([70.0, 22.0], egui::Label::new(egui::RichText::new(tl!("Brightness")).color(t.text_dim))); if let (Some(b), Some([h, s, _])) = (base, base_hsb) - && let (Some(nv), _) = color_slider(ui, (id, "brightness"), v, size, &|x| crate::panels::c32(&Color::from_hsb(h, s, x))) + && let (Some(nv), _) = + color_slider_named(ui, (id, "brightness"), tl!("Brightness"), v, size, &|x| crate::panels::c32(&Color::from_hsb(h, s, x))) { out.moved = Some((b, [h, s, nv])); } @@ -1916,6 +2039,7 @@ pub fn tab_bar(ui: &mut Ui, tabs: &[&str], current: usize) -> Option { t.accent, ); } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), sel, tl!(tab))); if resp.clicked() { clicked = Some(i); } @@ -1942,6 +2066,7 @@ pub fn orientation_button(ui: &mut Ui, landscape: bool, selected: bool, tip: &st ui.painter().add(egui::Shape::convex_polygon(outline.clone(), fill, Stroke::new(1.2, color))); let corner = [page.right_top() - vec2(fold, 0.0), page.right_top() + vec2(-fold, fold), page.right_top() + vec2(0.0, fold)]; ui.painter().add(egui::Shape::line(corner.to_vec(), Stroke::new(1.2, if selected { t.panel } else { color }))); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), selected, tl!(tip))); resp.on_hover_text(tl!(tip)).clicked() } @@ -1987,6 +2112,7 @@ pub fn layout_button(ui: &mut Ui, tiles: &[(u8, u8)], selected: bool, tip: &str) ui.painter().add(egui::Shape::line(path, Stroke::new(1.2, color))); ui.painter().add(egui::Shape::convex_polygon(head, color, Stroke::NONE)); } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, selected, tip)); resp.on_hover_text(tip).clicked() } @@ -2020,6 +2146,7 @@ fn chevron_cell(ui: &mut Ui, h: f32) -> Response { t.icon }; icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), Vec2::splat(12.0)), c); + dresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Presets"))); dresp } @@ -2044,6 +2171,159 @@ pub fn text_presets(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, val out } +#[cfg(test)] +pub(crate) mod accessibility_tests { + use egui::accesskit::{Node, Role, Toggled}; + + use super::*; + + /// The screen-reader tree of the second of two frames of `add` in a 1280 × 900 window, in + /// English. + pub(crate) fn tree(mut add: impl FnMut(&mut Ui)) -> Vec { + crate::i18n::set_current(crate::i18n::Lang::EN); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1280.0, 900.0)); + let mut nodes = vec![]; + for time in [0.0, 0.1] { + let input = egui::RawInput { time: Some(time), screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, &mut add); + out.textures_delta.clear(); + nodes = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default(); + } + nodes + } + + /// The node of `tree` that screen readers name `label`. + pub(crate) fn named<'a>(tree: &'a [Node], label: &str) -> &'a Node { + tree.iter().find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("nothing is named {label:?}")) + } + + /// The role of the node named `label`, and whether it is pressed, checked or chosen. + pub(crate) fn role_and_state(tree: &[Node], label: &str) -> (Role, Option) { + let n = named(tree, label); + (n.role(), n.toggled()) + } + + #[test] + fn buttons_are_named_by_their_text_or_tip() { + let tree = tree(|ui| { + icon_button(ui, "x", "Remove", false, 24.0); + flat_button(ui, "Make", 64.0); + split_button_named(ui, "Image Trace", "Tracing Presets", 104.0); + primary_button(ui, "OK"); + primary_button_enabled(ui, "Create", false); + secondary_button(ui, "Cancel"); + link_label(ui, "Stroke:"); + icon_button_enabled(ui, "trash-2", "Delete Brush", false, false, 24.0); + fill_stroke_proxy(ui, &Paint::None, &Paint::None, (false, false), true, 36.0); + chip_button_named(ui, 22.0, true, "Graphic Style", |_, _| {}); + spin_field(ui, "weight", Some(1.0), Unit::Points, 80.0, 1.0, 0.0, &[1.0, 2.0]); + opacity_blend(ui, "opacity", Some(1.0), Some(BlendMode::Normal), true); + }); + for label in [ + "Remove", + "Make", + "Image Trace", + "Tracing Presets", + "OK", + "Create", + "Cancel", + "Stroke:", + "Delete Brush", + "Swap Fill and Stroke (Shift+X)", + "Graphic Style", + "Step up or down", + "Presets", + "Opacity", + ] { + assert_eq!(role_and_state(&tree, label), (Role::Button, None), "{label}"); + } + assert_eq!(role_and_state(&tree, "Default Fill and Stroke (D)"), (Role::Button, None)); + assert_eq!(role_and_state(&tree, "Fill (X), double-click for the Color Picker"), (Role::Button, Some(Toggled::True))); + assert_eq!(role_and_state(&tree, "Stroke (X), double-click for the Color Picker"), (Role::Button, Some(Toggled::False))); + assert!(named(&tree, "Create").is_disabled(), "a primary button that is not enabled"); + assert!(named(&tree, "Delete Brush").is_disabled(), "an icon button that is not enabled"); + assert!(!named(&tree, "OK").is_disabled()); + } + + #[test] + fn toggles_say_whether_they_are_on() { + let tree = tree(|ui| { + icon_button(ui, "link", "Constrain Proportions", true, 24.0); + toggle_icon(ui, "eye", true, 18.0, "Show all plates"); + toggle_icon(ui, "eye", false, 18.0, "Show/hide plate (Alt-click: only this plate)"); + orientation_button(ui, false, true, "Portrait"); + orientation_button(ui, true, false, "Landscape"); + layout_button(ui, &[(0, 0), (1, 0)], true, "Grid by Row"); + tab_bar(ui, &["Recent", "Saved"], 1); + }); + let on = |label| assert_eq!(role_and_state(&tree, label), (Role::Button, Some(Toggled::True)), "{label}"); + let off = |label| assert_eq!(role_and_state(&tree, label), (Role::Button, Some(Toggled::False)), "{label}"); + on("Constrain Proportions"); + on("Show all plates"); + off("Show/hide plate (Alt-click: only this plate)"); + on("Portrait"); + off("Landscape"); + on("Grid by Row"); + off("Recent"); + on("Saved"); + } + + #[test] + fn checkboxes_and_radio_buttons_are_named_and_checked() { + let tree = tree(|ui| { + check(ui, "Preview", true, true); + check(ui, "Snap to Pixel", false, false); + check3(ui, "Overprint Fill", None, true); + check_name(ui, "Artboard 1", true); + check_box(ui, "Cyan", false, true); + radio(ui, "Relative", true, true); + radio(ui, "Absolute", false, true); + radio_button(ui, "per slice", true, true); + reference_point(ui, 4); + }); + assert_eq!(role_and_state(&tree, "Preview"), (Role::CheckBox, Some(Toggled::True))); + assert_eq!(role_and_state(&tree, "Snap to Pixel"), (Role::CheckBox, Some(Toggled::False))); + assert!(named(&tree, "Snap to Pixel").is_disabled()); + assert_eq!(role_and_state(&tree, "Overprint Fill"), (Role::CheckBox, Some(Toggled::Mixed))); + assert_eq!(role_and_state(&tree, "Artboard 1"), (Role::CheckBox, Some(Toggled::True))); + assert_eq!(role_and_state(&tree, "Relative"), (Role::RadioButton, Some(Toggled::True))); + assert_eq!(role_and_state(&tree, "Absolute"), (Role::RadioButton, Some(Toggled::False))); + assert_eq!(role_and_state(&tree, "Cyan"), (Role::CheckBox, Some(Toggled::False))); + assert_eq!(role_and_state(&tree, "per slice"), (Role::RadioButton, Some(Toggled::True))); + for (i, label) in ["Top Left", "Top", "Top Right", "Left", "Center", "Right", "Bottom Left", "Bottom", "Bottom Right"].into_iter().enumerate() + { + let chosen = if i == 4 { Toggled::True } else { Toggled::False }; + assert_eq!(role_and_state(&tree, label), (Role::RadioButton, Some(chosen)), "{label}"); + } + } + + #[test] + fn sliders_dials_and_combo_boxes_are_named() { + let red = Color::from_hsb(0.0, 1.0, 1.0); + let tree = tree(|ui| { + color_slider_named(ui, "fidelity", "Fidelity:", 0.25, 120.0, &|_| Color32::GRAY); + angle_dial(ui, "angle", 45.0, 28.0); + channel_slider_named(ui, "channel", "H", 0.5, vec2(22.0, 80.0), &|_| Color32::GRAY); + harmony_wheel(ui, "wheel", 120.0, &[red], Some(0)); + combo(ui, "font", "Regular", 120.0, true, |_| None::); + }); + let slider = |label| { + let n = named(&tree, label); + (n.role(), n.numeric_value()) + }; + assert_eq!(slider("Fidelity:"), (Role::Slider, None)); + assert_eq!(slider("Angle"), (Role::Slider, Some(45.0))); + assert_eq!(slider("H"), (Role::Slider, None)); + assert_eq!(slider("Color wheel"), (Role::Slider, None)); + assert_eq!(slider("Brightness"), (Role::Slider, None)); + assert!(tree.iter().any(|n| n.role() == Role::ComboBox && n.value() == Some("Regular")), "the searchable combo box shows its text"); + } +} + #[cfg(test)] mod spinner_modifier_tests { use super::*; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f8f68e48a..de7b0e9c4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -431,6 +431,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's tool-specific variants (only the selection tools show it). - With one image selected, the contextual task bar offers Image Trace, which traces the image at once with the Default preset (the Image Trace panel is left as it was), and Crop Image; with an Image Trace object it offers Expand, which keeps only the traced paths. As in Illustrator ("Selecting images displays options like Image Trace, Retype, and Crop Image"; "You can also quickly expand traced artwork from the bar": [Contextual Task Bar announcement](https://community.adobe.com/announcements-651/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580)). Offset Path and Simplify, which don't apply to an image, aren't offered for one. Not done: Retype, which the app doesn't have. - Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it. + - Screen readers get a name for the Tools panel's tool buttons, flyout rows, floating strips and tear-off bars, and for the icon buttons, text buttons, toggles, checkboxes, radio buttons, tabs, chips, sliders, angle dials and combo boxes that panels and dialogs draw with `crates/ui-egui/src/widgets.rs`: the text, label or tool tip each one shows, or a name of its own where it shows none (the angle dial, the spinner, the preset arrow, the color wheel and the Reference Point's squares). The tool buttons, toggles, checkboxes, radio buttons, tabs and the Reference Point's nine squares also report whether they are on, an icon button that is lit, such as the chosen Align To button, reports that it is pressed, and the angle dial reports its angle. The status bar's First, Previous, Next and Last Artboard buttons have tool tips. - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into the default tabbed group it doesn't belong to, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set. - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels; new variable keys fall back to English in other languages. Evidence: `i18n::tests::czech_render_site_follow_up`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). From 3fc78187ecb0fccdb37d8d398d05f641da083dc1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 15:21:31 -0700 Subject: [PATCH 076/208] TIFF: planar grayscale files with extra channels, and planar CMYK files, open and place TIFFs that store each channel as its own plane (PlanarConfiguration 2) did not open, place or relink when they were CMYK or grayscale with extra channels: the image decoder refuses planar CMYK, and the readers for CMYK inks and for extra samples returned nothing for planar files. The two readers now read every plane through the tiff crate and put each pixel's samples together. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. Grayscale TIFFs with an alpha channel or other extra channels, and CMYK TIFFs with alpha, read as their chunky versions do. A TIFF whose header claims more strips or tiles than the tiff decoder can count is refused as too large to draw. The decoder multiplies those counts without a check, which panics in builds with overflow checks. Part of #1466. Docs: docs/file-format-parity.md, docs/target-app-parity.md. --- crates/doc/src/cmyk.rs | 237 ++++++++++++++++++++++---- crates/engine/src/cmd/fileio/load.rs | 28 ++- crates/engine/src/cmd/fileio/tests.rs | 94 ++++++++++ crates/engine/tests/import_fuzz.rs | 13 +- crates/testkit/src/lib.rs | 2 + crates/testkit/src/tiff.rs | 116 +++++++++++++ docs/file-format-parity.md | 1 + docs/target-app-parity.md | 3 +- 8 files changed, 451 insertions(+), 43 deletions(-) create mode 100644 crates/testkit/src/tiff.rs diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index e4c73f28a..2ca605db0 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -2,10 +2,12 @@ //! //! Two encodings hold them: a four-component JPEG, whose samples are stored inverted with Adobe's //! marker (the convention JPEG readers and PDF writers take a CMYK JPEG to follow), and a CMYK -//! TIFF (8 or 16 bits, chunky, ink amounts as they are). [`ImageBlob::cmyk_tiff`] writes the -//! latter, Deflate-compressed. Every other image decodes to RGB(A) only. +//! TIFF (8 or 16 bits, ink amounts as they are, each pixel's inks together or one plane per ink). +//! [`ImageBlob::cmyk_tiff`] writes the latter, inks together, Deflate-compressed. Every other image +//! decodes to RGB(A) only. use std::io::Cursor; +use std::sync::Arc; use zune_jpeg::zune_core::bytestream::ZCursor; use zune_jpeg::zune_core::colorspace::ColorSpace; @@ -54,25 +56,136 @@ fn is_tiff(bytes: &[u8]) -> bool { bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") } -/// The colour type of TIFF `bytes` and its decoder. +/// The width and height of the first image of TIFF `bytes` when it has more strips or tiles than +/// the `tiff` decoder can count: strips per plane times planes past `u32::MAX`, or tiles per plane +/// times planes past `usize::MAX`. A file that stores one plane per channel (PlanarConfiguration 2) +/// has a plane for each channel; other files have one. The decoder multiplies these counts without +/// a check, which panics where overflow checks are on. Such an image, when it is at least a pixel +/// wide, claims at least 2^32 samples. +/// Where a tag repeats, the value giving the most strips or tiles counts. `None` for other images +/// and for bytes that aren't a TIFF. +pub fn oversized_tiff(bytes: &[u8]) -> Option<(u32, u32)> { + let le = bytes.starts_with(b"II*\0"); + if !le && !bytes.starts_with(b"MM\0*") { + return None; + } + let u16_at = |at: usize| -> Option { + let b: [u8; 2] = bytes.get(at..at.checked_add(2)?)?.try_into().ok()?; + Some(if le { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) }) + }; + let u32_at = |at: usize| -> Option { + let b: [u8; 4] = bytes.get(at..at.checked_add(4)?)?.try_into().ok()?; + Some(if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) }) + }; + let ifd = usize::try_from(u32_at(4)?).ok()?; + // (tag, the entry's single BYTE, SHORT, LONG or IFD value, which sits in the entry, or its + // single LONG8 or IFD8 value that fits in 32 bits, which sits where the entry points) + let entry = |i: usize| -> Option<(u16, Option)> { + let e = ifd.checked_add(i.checked_mul(12)?.checked_add(2)?)?; + let (ty, count, at) = (u16_at(e.checked_add(2)?)?, u32_at(e.checked_add(4)?)?, e.checked_add(8)?); + let value = match (ty, count) { + (1, 1) => bytes.get(at).map(|&b| u32::from(b)), + (3, 1) => u16_at(at).map(u32::from), + (4 | 13, 1) => u32_at(at), + (16 | 18, 1) => { + let at = usize::try_from(u32_at(at)?).ok()?; + let (lo, hi) = (u32_at(at)?, u32_at(at.checked_add(4)?)?); + let wide = if le { u64::from(hi) << 32 | u64::from(lo) } else { u64::from(lo) << 32 | u64::from(hi) }; + u32::try_from(wide).ok() + } + _ => None, + }; + Some((u16_at(e)?, value)) + }; + let (mut width, mut height, mut planes, mut planar) = (0u32, 0u32, 1u32, false); + let (mut rows, mut tile_w, mut tile_h) = (None::, None::, None::); + let least = |was: Option, v: u32| Some(was.map_or(v, |w| w.min(v))); + for i in 0..usize::from(u16_at(ifd)?) { + let Some((tag, value)) = entry(i) else { break }; + let Some(v) = value else { continue }; + match tag { + 256 => width = width.max(v), + 257 => height = height.max(v), + 277 => planes = planes.max(v), + 278 => rows = least(rows, v), + 284 => planar |= v == 2, + 322 => tile_w = least(tile_w, v), + 323 => tile_h = least(tile_h, v), + _ => {} + } + } + let planes = if planar { u128::from(planes) } else { 1 }; + let tiles = match (tile_w, tile_h) { + (Some(tw @ 1..), Some(th @ 1..)) => u128::from(width.div_ceil(tw)) * u128::from(height.div_ceil(th)) * planes > usize::MAX as u128, + _ => false, + }; + let rows = rows.unwrap_or(height); + let strips = rows > 0 && u128::from(height.saturating_sub(1) / rows + 1) * planes > u128::from(u32::MAX); + (tiles || strips).then_some((width, height)) +} + +/// The color type of TIFF `bytes` and its decoder; `None` for [`oversized_tiff`] images too. fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder>)> { - if !is_tiff(bytes) { + if !is_tiff(bytes) || oversized_tiff(bytes).is_some() { return None; } let mut d = tiff::decoder::Decoder::new(Cursor::new(bytes)).ok()?; Some((d.colortype().ok()?, d)) } +/// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)? +fn planar(d: &mut tiff::decoder::Decoder>) -> bool { + d.find_tag_unsigned::(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1) +} + +/// The samples of the `n` pixels of the TIFF `d` reads, `per` to a pixel (the number its color +/// type has), pixel by pixel and 8 bits each (16-bit samples to their high byte), whether the file +/// stores each pixel's samples together or one plane per channel. `None` when they don't decode to +/// that many samples, which includes images whose planes together need more than the decoder's +/// limit (256 MB of samples). +fn read_samples(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option> { + let mut buf = tiff::decoder::DecodingResult::U8(Vec::new()); + let layout = d.read_image_to_buffer(&mut buf).ok()?; + let data = match buf { + tiff::decoder::DecodingResult::U8(v) => v, + tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(), + _ => return None, + }; + if Some(data.len()) != n.checked_mul(per) { + return None; + } + if layout.planes <= 1 { + return Some(data); + } + if layout.planes != per { + return None; + } + let planes: Vec<&[u8]> = data.chunks_exact(n).collect(); + Some((0..n).flat_map(|i| planes.iter().map(move |p| p.get(i).copied().unwrap_or(0))).collect()) +} + impl ImageBlob { /// A CMYK TIFF of ink amounts `inks` (Deflate-compressed); `None` when it can't be written. pub fn cmyk_tiff(inks: &Inks) -> Option { - use tiff::encoder::{Compression, TiffEncoder, colortype::CMYK8, compression::DeflateLevel}; + Self::cmyk_tiff_at(inks, None) + } + + /// [`ImageBlob::cmyk_tiff`], declaring the resolution `ppi` (horizontal, vertical pixels per + /// inch) when it is given and both are finite and above 0. + pub fn cmyk_tiff_at(inks: &Inks, ppi: Option<(f64, f64)>) -> Option { + use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::CMYK8, compression::DeflateLevel}; + use tiff::tags::{ResolutionUnit, Tag}; let mut out = Cursor::new(Vec::new()); - TiffEncoder::new(&mut out) - .ok()? - .with_compression(Compression::Deflate(DeflateLevel::Balanced)) - .write_image::(inks.width, inks.height, &inks.data) - .ok()?; + let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced)); + let mut img = enc.new_image::(inks.width, inks.height).ok()?; + if let Some((x, y)) = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0)) { + // Ten-thousandths of a pixel per inch. + let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 }; + img.encoder().write_tag(Tag::XResolution, r(x)).ok()?; + img.encoder().write_tag(Tag::YResolution, r(y)).ok()?; + img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?; + } + img.write_data(&inks.data).ok()?; Some(Self::new("image/tiff", out.into_inner())) } @@ -119,34 +232,42 @@ fn jpeg_inks(bytes: &[u8]) -> Option { Inks::new(w, h, data) } -/// The ink amounts of a chunky CMYK TIFF (16-bit samples to 8 bits). +/// The ink amounts of a CMYK TIFF (16-bit samples to 8 bits), its inks stored together or one plane +/// per ink. fn tiff_inks(bytes: &[u8]) -> Option { let (kind, mut d) = tiff(bytes)?; let (w, h) = d.dimensions().ok()?; - // Planar samples (one plane per ink) would read as interleaved. - let planar = d.find_tag_unsigned::(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1); - if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) || planar || pixels(w, h).is_none() { + if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) { return None; } - let data = match d.read_image().ok()? { - tiff::decoder::DecodingResult::U8(v) => v, - tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(), - _ => return None, - }; - Inks::new(w, h, data) + let n = usize::try_from(pixels(w, h)?).ok()?; + Inks::new(w, h, read_samples(&mut d, n, 4)?) +} + +/// CMYK TIFF `bytes` as Open and Place store them: as they are, or, when they store one plane per +/// ink, which the image decoder doesn't read, their ink amounts written again by +/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi`. `None` for other images and for CMYK +/// TIFFs whose inks [`ImageBlob::cmyk`] doesn't read. +pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option> { + let inks = tiff_inks(bytes)?; + let (_, mut d) = tiff(bytes)?; + if !planar(&mut d) { + return Some(bytes.to_vec()); + } + ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes)) } /// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at /// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink /// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB), /// and grayscale with extra samples, of which the first is the alpha when the file says so (other -/// channels are left out). Associated (premultiplied) alpha is divided back out. `None` for other -/// images, planar ones and ones larger than [`MAX_CMYK_PIXELS`]. +/// channels are left out). Associated (premultiplied) alpha is divided back out. The samples may be +/// stored together or one plane per channel. `None` for other images and ones larger than +/// [`MAX_CMYK_PIXELS`]. pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option { use tiff::tags::Tag; let (kind, mut d) = tiff(bytes)?; let (w, h) = d.dimensions().ok()?; - let planar = d.find_tag_unsigned::(Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1); // 0: white is zero, 1: black is zero (the two grayscales). let photometric = d.find_tag_unsigned::(Tag::PhotometricInterpretation).ok().flatten(); // ExtraSamples 1: associated alpha (premultiplied colour), 2: unassociated alpha, 0: not alpha. @@ -159,18 +280,8 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) } _ => return None, }; - if planar { - return None; - } let n = usize::try_from(pixels(w, h)?).ok()?; - let data: Vec = match d.read_image().ok()? { - tiff::decoder::DecodingResult::U8(v) => v, - tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(), - _ => return None, - }; - if Some(data.len()) != n.checked_mul(samples) { - return None; - } + let data = read_samples(&mut d, n, samples)?; let mut out = Vec::with_capacity(n * 4); for p in data.chunks_exact(samples) { let opacity = if alpha { p.get(colour).copied().unwrap_or(u8::MAX) } else { u8::MAX }; @@ -246,6 +357,64 @@ mod tests { assert_eq!(gray(0), [255, 255, 255, 255, 51, 51, 51, 255, 0, 0, 0, 255], "a channel that isn't alpha: opaque"); } + /// A header claiming more tiles than can be counted: (2^32 − 1)² pixels in tiles of one pixel, + /// times 2 planes. + #[test] + fn tiffs_with_more_tiles_than_can_be_counted_are_oversized() { + let header = |planar: u32| { + let mut f = b"II*\0\x08\0\0\0".to_vec(); + let entries = [(256u16, u32::MAX), (257, u32::MAX), (258, 8), (262, 1), (277, 2), (284, planar), (322, 1), (323, 1), (324, 0), (325, 0)]; + f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes()); + for (tag, v) in entries { + f.extend([tag.to_le_bytes(), 4u16.to_le_bytes()].concat()); + f.extend([1u32.to_le_bytes(), v.to_le_bytes()].concat()); + } + f.extend([0; 4]); + f + }; + assert_eq!(oversized_tiff(&header(2)), Some((u32::MAX, u32::MAX))); + assert_eq!(oversized_tiff(&header(1)), None, "one plane: (2^32 − 1)² tiles can be counted in 64 bits"); + assert!(tiff(&header(2)).is_none(), "not decoded"); + } + + /// A header claiming more strips than can be counted: 2^31 + 1 rows, a strip each, times 2 + /// planes. The decoder counts strips when the file has strip offsets, tile sizes or not, and + /// reads a LONG8 value from where the entry points. + #[test] + fn tiffs_with_more_strips_than_can_be_counted_are_oversized() { + // Each entry holds one value of type 3 (SHORT), 4 (LONG) or 16 (LONG8, after the directory). + let header = |entries: &[(u16, u16, u32)]| { + let after = 8 + 2 + 12 * entries.len() + 4; + let mut f = b"II*\0\x08\0\0\0".to_vec(); + f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes()); + let mut wide = vec![]; + for &(tag, ty, v) in entries { + f.extend([tag.to_le_bytes(), ty.to_le_bytes()].concat()); + f.extend(1u32.to_le_bytes()); + if ty == 16 { + f.extend(u32::try_from(after + wide.len()).unwrap().to_le_bytes()); + wide.extend(u64::from(v).to_le_bytes()); + } else { + f.extend(v.to_le_bytes()); + } + } + f.extend([0; 4]); + f.extend(wide); + f + }; + let tall = 1u32 << 31 | 1; + let strips = [(256, 4, 1), (257, 4, tall), (258, 3, 8), (262, 3, 1), (273, 4, 0), (277, 3, 2), (278, 4, 1), (279, 4, 0), (284, 3, 2)]; + assert_eq!(oversized_tiff(&header(&strips)), Some((1, tall))); + let tile_sizes = [&strips[..], &[(322, 4, u32::MAX), (323, 4, u32::MAX)]].concat(); + assert_eq!(oversized_tiff(&header(&tile_sizes)), Some((1, tall)), "tile sizes without tile offsets"); + let mut long8 = strips; + long8[1] = (257, 16, tall); + assert_eq!(oversized_tiff(&header(&long8)), Some((1, tall)), "ImageLength as a LONG8"); + for h in [header(&tile_sizes), header(&long8)] { + assert!(tiff(&h).is_none(), "not decoded"); + } + } + fn inks() -> Inks { Inks::new(3, 2, vec![0, 40, 100, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 12, 34, 56, 78]).unwrap() } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 3834aa7e1..463517974 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -447,18 +447,28 @@ pub(crate) use vectorcraft_doc::MAX_RASTER_ALLOC; /// decode, and for bytes that aren't an image. pub(crate) fn too_large_note(bytes: &[u8]) -> Option { use image::ImageDecoder as _; + if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { + return Some(format!("an image of {w} × {h} pixels is too large to draw")); + } let d = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?; let (w, h) = d.dimensions(); (d.total_bytes() > MAX_RASTER_ALLOC).then(|| format!("an image of {w} × {h} pixels is too large to draw")) } /// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as -/// they are, with their ink amounts ([`ImageBlob::cmyk`]); CMYK TIFFs with an alpha channel, which -/// the decoder can't read, become RGBA in the active colour settings' CMYK, as do CMYK Photoshop -/// documents, whose merged image is read ([`psdread`]). Grayscale TIFFs with an alpha channel or -/// other extra channels, which the decoder can't read either, become RGBA too. +/// they are, with their ink amounts ([`ImageBlob::cmyk`]), except that ones storing one plane per +/// ink are written again with their inks together ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]); +/// CMYK TIFFs with an alpha channel, which the decoder can't read, become RGBA in the active color +/// settings' CMYK, as do CMYK Photoshop documents, whose merged image is read ([`psdread`]). +/// Grayscale TIFFs with an alpha channel or other extra channels, which the decoder can't read +/// either, become RGBA too, whether they store their channels together or one plane per channel. +/// A TIFF with more strips or tiles than the decoder can count +/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. pub fn raster_image(bytes: &[u8]) -> Result { let ppi = super::ppi::resolution(bytes); + if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { + return Err(err(format!("an image of {w} × {h} pixels is too large to draw"))); + } if psdread::is_psd(bytes) { let cms = vectorcraft_color::cms::active(); return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi); @@ -469,10 +479,14 @@ pub fn raster_image(bytes: &[u8]) -> Result { reader.limits(limits); let kind = reader.format().ok_or_else(|| err("not an image VectorCraft reads (see document.formats)"))?; let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?; - let cmyk = || ImageBlob::new(f.mime, bytes.to_vec()).cmyk().is_some(); - if matches!(f.id, "png" | "jpg" | "gif" | "webp") || (f.id == "tiff" && cmyk()) { + let kept = match f.id { + "png" | "jpg" | "gif" | "webp" => Some(bytes.to_vec()), + "tiff" => vectorcraft_doc::cmyk::stored_cmyk_tiff(bytes, ppi), + _ => None, + }; + if let Some(kept) = kept { let (width, height) = reader.into_dimensions().map_err(err)?; - return stored(bytes.to_vec(), f.mime, (width, height), ppi); + return stored(kept, f.mime, (width, height), ppi); } let extra_samples = || { let cms = vectorcraft_color::cms::active(); diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 3819c842a..92eb5b7fd 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -114,6 +114,100 @@ fn cmyk_tiffs_keep_their_inks_and_other_tiffs_become_png() { assert_eq!(rgb.blob.mime, "image/png"); } +/// A 3 × 2 TIFF at 150 ppi of `channels` (`extra`: their ExtraSamples values), 8 or 16 `bits` a +/// sample, chunky or `planar`. +fn hand_tiff(photometric: u16, bits: u16, extra: &[u16], planar: bool, channels: &[Vec]) -> Vec { + let t = vectorcraft_testkit::tiff::Tiff { width: 3, height: 2, photometric, bits, extra, planar, ppi: Some(150), channels }; + vectorcraft_testkit::tiff::tiff(&t) +} + +/// What Place keeps of TIFF `bytes`: the ink amounts, when it keeps them, and the pixels any image +/// reader decodes from the stored image, as RGBA. +fn kept(what: &str, bytes: &[u8]) -> (Option>, Vec) { + let r = raster_image(bytes).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!((r.width, r.height, r.ppi), (3, 2, Some((150.0, 150.0))), "{what}"); + assert_eq!(super::ppi::resolution(&r.blob.bytes), Some((150.0, 150.0)), "{what}: the stored image keeps the resolution"); + let rgba = image::load_from_memory(&r.blob.bytes).unwrap_or_else(|e| panic!("{what}: {e}")).to_rgba8().into_raw(); + (r.blob.cmyk().map(|i| i.data), rgba) +} + +/// TIFFs that store one plane per channel (PlanarConfiguration 2) read as their chunky versions do: +/// grayscale and RGB with extra channels, and CMYK with and without them (#1466). +#[test] +fn planar_tiffs_read_as_their_chunky_versions() { + let ch = |v: [u16; 6]| v.to_vec(); + let (gray, alpha, spot) = (ch([0, 51, 102, 153, 204, 255]), ch([255, 128, 0, 255, 64, 255]), ch([9, 8, 7, 6, 5, 4])); + let (r, g, b) = (ch([255, 0, 0, 10, 20, 30]), ch([0, 255, 0, 40, 50, 60]), ch([0, 0, 255, 70, 80, 90])); + let (c, m, y, k) = (ch([255, 0, 0, 0, 12, 200]), ch([0, 255, 0, 0, 34, 100]), ch([0, 0, 255, 0, 56, 50]), ch([0, 0, 0, 255, 78, 0])); + // Pixel by pixel, the samples of `px`. + let interleave = |px: &[&Vec]| -> Vec { (0..6).flat_map(|i| px.iter().map(move |v| v[i] as u8)).collect() }; + let opaque = ch([255; 6]); + let inks = interleave(&[&c, &m, &y, &k]); + // (photometric, ExtraSamples, channels, the RGBA pixels when the color is gray or RGB, the inks when it is CMYK) + type Case = (u16, Vec, Vec>, Option>, Option>); + let cases: Vec = vec![ + (1, vec![2], vec![gray.clone(), alpha.clone()], Some(interleave(&[&gray, &gray, &gray, &alpha])), None), + (1, vec![2, 0], vec![gray.clone(), alpha.clone(), spot.clone()], Some(interleave(&[&gray, &gray, &gray, &alpha])), None), + (2, vec![0], vec![r.clone(), g.clone(), b.clone(), spot.clone()], Some(interleave(&[&r, &g, &b, &opaque])), None), + (2, vec![2, 0], vec![r.clone(), g.clone(), b.clone(), alpha.clone(), spot.clone()], Some(interleave(&[&r, &g, &b, &alpha])), None), + (5, vec![], vec![c.clone(), m.clone(), y.clone(), k.clone()], None, Some(inks.clone())), + (5, vec![0], vec![c.clone(), m.clone(), y.clone(), k.clone(), spot.clone()], None, Some(inks.clone())), + (5, vec![2], vec![c.clone(), m.clone(), y.clone(), k.clone(), alpha.clone()], None, None), + ]; + for (photometric, extra, channels, want_rgba, want_inks) in cases { + for bits in [8, 16] { + let wide: Vec> = channels.iter().map(|ch| ch.iter().map(|v| if bits == 16 { v * 257 } else { *v }).collect()).collect(); + let what = format!("photometric {photometric}, extra samples {extra:?}, {bits} bits"); + let chunky = kept(&format!("{what}, chunky"), &hand_tiff(photometric, bits, &extra, false, &wide)); + let planar = kept(&format!("{what}, planar"), &hand_tiff(photometric, bits, &extra, true, &wide)); + assert_eq!(planar.0, chunky.0, "{what}: planar keeps the inks chunky keeps"); + assert_eq!(planar.0, want_inks, "{what}: the inks"); + // A 16-bit chunky CMYK file is stored as it is, and a planar one is written again with + // its 8-bit inks: their RGB pixels may be a level apart. + let close = planar.1.len() == chunky.1.len() && planar.1.iter().zip(&chunky.1).all(|(a, b)| a.abs_diff(*b) <= 1); + assert!(close, "{what}: planar reads as chunky: {:?} against {:?}", planar.1, chunky.1); + if let Some(want) = &want_rgba { + assert_eq!(&planar.1, want, "{what}: the pixels"); + } + } + } +} + +/// A planar TIFF whose header claims more strips than the decoder can count (here 2^31 + 1 rows +/// a strip each, times 5 planes) is refused as too large to draw, without a panic. +#[test] +fn planar_tiffs_with_more_strips_than_can_be_counted_are_too_large() { + let plane = |v: u16| vec![v; 6]; + let mut file = hand_tiff(5, 8, &[0], true, &[plane(200), plane(40), plane(10), plane(0), plane(90)]); + // ImageLength (the directory's second entry, its value at offset 30) and RowsPerStrip (the + // eighth, at offset 102). + file[30..34].copy_from_slice(&(1u32 << 31 | 1).to_le_bytes()); + file[102..106].copy_from_slice(&1u32.to_le_bytes()); + let e = raster_image(&file).err().expect("refused"); + assert!(e.to_string().contains("an image of 3 × 2147483649 pixels is too large to draw"), "{e}"); + assert!(too_large_note(&file).is_some()); + assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1))); +} + +/// A planar CMYK TIFF opens and places with its ink amounts, stored as a TIFF the image decoder +/// reads (#1466). +#[test] +fn planar_cmyk_tiffs_open_and_place_with_their_inks() { + let inks = [vec![255u16, 0, 12, 0, 40, 9], vec![0, 255, 34, 0, 50, 8], vec![0, 0, 56, 0, 60, 7], vec![0, 0, 78, 255, 70, 6]]; + let file = hand_tiff(5, 8, &[], true, &inks); + let want: Vec = (0..6).flat_map(|i| inks.iter().map(move |v| v[i] as u8)).collect(); + assert!(image::load_from_memory(&file).is_err(), "the image decoder reads planar CMYK now"); + let mut s = Session::new(); + open(&mut s, "planar.tif", &file); + let blob = &s.doc().unwrap().doc.images[&image_of(&s).key]; + assert_eq!(blob.cmyk().map(|i| i.data), Some(want.clone()), "Open keeps the inks"); + let mut s = session(100.0, 100.0, 1); + s.execute("file.place", &json!({"name": "planar.tif", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap(); + let blob = &s.doc().unwrap().doc.images[&image_of(&s).key]; + assert_eq!(blob.cmyk().map(|i| i.data), Some(want), "Place keeps the inks"); + assert!(image::load_from_memory(&blob.bytes).is_ok(), "the image decoder reads the stored image"); +} + #[test] fn content_beats_a_wrong_extension() { let png = image_bytes(2, 2, image::ImageFormat::Png); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index faac93f82..21a877dcc 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1303,6 +1303,17 @@ fn raster_samples() -> Vec<(&'static str, Vec)> { out.push((name, b)); } out[0].1 = vectorcraft_engine::cmd::fileio::ppi::with_png_resolution(&out[0].1, (300.0, 150.0)); + // TIFFs that store one plane per channel: CMYK with a channel after the inks, and gray with + // alpha, 16 bits a sample. + let plane = |v: u16| vec![v; 15]; + let cmyk = [plane(200), plane(40), plane(10), plane(0), plane(90)]; + let cmyk = + vectorcraft_testkit::tiff::Tiff { width: 5, height: 3, photometric: 5, bits: 8, extra: &[0], planar: true, ppi: Some(300), channels: &cmyk }; + out.push(("planar-cmyk.tif", vectorcraft_testkit::tiff::tiff(&cmyk))); + let gray = [plane(30_000), plane(65_535)]; + let gray = + vectorcraft_testkit::tiff::Tiff { width: 5, height: 3, photometric: 1, bits: 16, extra: &[2], planar: true, ppi: None, channels: &gray }; + out.push(("planar-gray.tif", vectorcraft_testkit::tiff::tiff(&gray))); // A layered PSD with transparency (as VectorCraft exports it), and a PackBits PSB by hand. let mut s = rich_session(); let r = s.execute("document.export", &json!({"format": "psd", "ppi": 2})).unwrap(); @@ -1325,7 +1336,7 @@ proptest! { /// Mutated image headers (resolution metadata, chunk and segment lengths) never crash reading /// their resolution or placing them. #[test] - fn mutated_images_place_without_panics(which in 0usize..6, cut in 0usize..4000, edits in prop::collection::vec((0usize..120, any::()), 0..12)) { + fn mutated_images_place_without_panics(which in 0usize..8, cut in 0usize..4000, edits in prop::collection::vec((0usize..120, any::()), 0..12)) { let (name, mut bytes) = raster_samples().swap_remove(which); for (at, b) in edits { if let Some(x) = bytes.get_mut(at) { diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index af8b7d47c..f0091bcd3 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -12,6 +12,7 @@ //! - [`pdf`]: hand-written PDF files (page boxes, colour spaces, encryption) for import tests. //! - [`ai`]: hand-written Illustrator editing data, and the EPS and `.ai` files that carry it. //! - [`ase`]: hand-written swatch exchange (`.ase`) files for swatch library tests. +//! - [`tiff`]: hand-written TIFF files, chunky or planar, for image import tests. //! - [`fonts`]: font files made from the bundled fonts (a renamed family) for font tests. //! //! This crate may only be used as a dev-dependency (enforced by `cargo xtask layers`). @@ -30,6 +31,7 @@ pub mod invariants; pub mod pdf; pub mod raster; pub mod strategies; +pub mod tiff; pub use serde_json::{Value, json}; pub use vectorcraft_engine::{Session, doc::NodeId}; diff --git a/crates/testkit/src/tiff.rs b/crates/testkit/src/tiff.rs new file mode 100644 index 000000000..d31a66681 --- /dev/null +++ b/crates/testkit/src/tiff.rs @@ -0,0 +1,116 @@ +//! Hand-written TIFF files for import tests: little-endian, uncompressed, 8 or 16 bits a sample, +//! with each pixel's samples stored together (chunky) or one plane per sample (planar, +//! PlanarConfiguration 2) (TIFF 6.0, sections 2 and 8). + +/// One image: its channels in the order the file stores them, color first, extra samples after. +#[derive(Clone, Debug, Default)] +pub struct Tiff<'a> { + pub width: u32, + pub height: u32, + /// PhotometricInterpretation: 0 white is zero, 1 black is zero, 2 RGB, 5 separated (CMYK). + pub photometric: u16, + /// Bits a sample: 8 or 16. + pub bits: u16, + /// The ExtraSamples values, one for each channel after the color (0 not alpha, 1 associated + /// alpha, 2 unassociated alpha); none writes no ExtraSamples tag. + pub extra: &'a [u16], + /// One plane per channel (PlanarConfiguration 2) instead of each pixel's samples together. + pub planar: bool, + /// Pixels per inch, written as XResolution and YResolution with ResolutionUnit 2. + pub ppi: Option, + /// The channels: `width` × `height` samples each, row by row. + pub channels: &'a [Vec], +} + +/// Pads `out` to an even length: TIFF offsets point at word boundaries. +fn align(out: &mut Vec) { + if out.len() % 2 == 1 { + out.push(0); + } +} + +/// A directory entry: its tag, field type, count and value bytes. +type Entry = (u16, u16, usize, Vec); + +/// The TIFF file of `t`: the header, the directory at offset 8, the values longer than four +/// bytes, then the image in one strip a plane. +pub fn tiff(t: &Tiff) -> Vec { + let pixels = usize::try_from(t.width * t.height).unwrap(); + assert!(t.channels.iter().all(|c| c.len() == pixels), "every channel has a sample a pixel"); + assert!(matches!(t.bits, 8 | 16), "8 or 16 bits a sample"); + let mut strips: Vec> = vec![]; + let samples_of = |i: usize| t.channels.iter().map(move |c| c[i]); + let order: Vec> = if t.planar { t.channels.to_vec() } else { vec![(0..pixels).flat_map(samples_of).collect()] }; + for values in order { + strips.push(match t.bits { + 8 => values.iter().map(|&v| u8::try_from(v).unwrap()).collect(), + _ => values.iter().flat_map(|v| v.to_le_bytes()).collect(), + }); + } + let samples = u16::try_from(t.channels.len()).unwrap(); + let counts: Vec = strips.iter().map(|s| u32::try_from(s.len()).unwrap()).collect(); + let entries = |offsets: &[u32]| -> Vec { + let shorts = |tag, v: &[u16]| (tag, 3, v.len(), v.iter().flat_map(|x| x.to_le_bytes()).collect()); + let longs = |tag, v: &[u32]| (tag, 4, v.len(), v.iter().flat_map(|x| x.to_le_bytes()).collect()); + let rational = |tag, n: u32| (tag, 5, 1, [n.to_le_bytes(), 1u32.to_le_bytes()].concat()); + // In ascending tag order. + let mut e = vec![ + longs(256, &[t.width]), + longs(257, &[t.height]), + shorts(258, &vec![t.bits; usize::from(samples)]), + shorts(259, &[1]), + shorts(262, &[t.photometric]), + longs(273, offsets), + shorts(277, &[samples]), + longs(278, &[t.height]), + longs(279, &counts), + ]; + if let Some(ppi) = t.ppi { + e.extend([rational(282, ppi), rational(283, ppi)]); + } + e.push(shorts(284, &[if t.planar { 2 } else { 1 }])); + if t.ppi.is_some() { + e.push(shorts(296, &[2])); + } + if !t.extra.is_empty() { + e.push(shorts(338, t.extra)); + } + e + }; + // The strips' offsets follow from the sizes of the rest, which don't depend on them. + let padded = |n: usize| n + n % 2; + let sized = entries(&vec![0; strips.len()]); + let ifd_len = 2 + 12 * sized.len() + 4; + let values_len: usize = sized.iter().filter(|e| e.3.len() > 4).map(|e| padded(e.3.len())).sum(); + let mut at = 8 + ifd_len + values_len; + let mut offsets = vec![]; + for s in &strips { + offsets.push(u32::try_from(at).unwrap()); + at += padded(s.len()); + } + let mut out = b"II*\0\x08\0\0\0".to_vec(); + let entries = entries(&offsets); + out.extend(u16::try_from(entries.len()).unwrap().to_le_bytes()); + let mut values = vec![]; + for (tag, ty, count, bytes) in entries { + out.extend(tag.to_le_bytes()); + out.extend(ty.to_le_bytes()); + out.extend(u32::try_from(count).unwrap().to_le_bytes()); + if bytes.len() <= 4 { + out.extend(&bytes); + out.extend(vec![0; 4 - bytes.len()]); + } else { + out.extend(u32::try_from(8 + ifd_len + values.len()).unwrap().to_le_bytes()); + values.extend(bytes); + align(&mut values); + } + } + out.extend([0; 4]); + out.extend(values); + for s in strips { + out.extend(s); + align(&mut out); + } + assert_eq!(out.len(), at, "the strips sit where the directory says"); + out +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7b6fb8b40..516e64f71 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -101,6 +101,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | | PSD | open, place, export | yes (merged image, every mode and depth) | yes (layered, max editability) | placing as layers (Photoshop Import Options) missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | | PSB | open | yes (merged image) | no | | +| PNG / JPEG / GIF / BMP / TIFF / Targa | open, export | yes (Targa: export only) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa import missing; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), and so do files that store each channel as its own plane (PlanarConfiguration 2), CMYK ones with their ink amounts; CCITT Group 3 doesn't; the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584) | | PNG / JPEG / GIF / BMP / TIFF / Targa | open, export | yes (Targa: export only) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa import missing; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), CCITT Group 3 and planar files with extra channels don't; the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | | WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | | HEIC / HEIF | open, place | no | no | | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1806283db..a3d40fa66 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -617,7 +617,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points. Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests_links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). - - 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; CCITT Group 3 and planar files with extra channels are not read); 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). + - 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; CCITT Group 3 files are not read); 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). + - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked; CIRCLE/ARC fitting, CNC preset and VCarve QA remain pending. - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). From 6902e1ab3fae692a39228124eb9be6859b88432c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:49:07 +0700 Subject: [PATCH 077/208] Dialogs: drop the slider wrappers nothing calls since the named ones --- crates/ui-egui/src/dialogs/form.rs | 27 +++++---------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 82b3cb8db..d158db42a 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -480,20 +480,8 @@ pub(super) fn slider_w( }); } -/// The rail and value field of [`slider`] without its label (for a row that draws its own). -#[allow(dead_code)] -pub(super) fn slider_field( - ui: &mut egui::Ui, - d: &mut Dialog, - key: &str, - range: std::ops::RangeInclusive, - suffix: &str, - track: &dyn Fn(f32) -> egui::Color32, -) { - slider_field_named(ui, d, key, "", range, suffix, track); -} - -/// [`slider_field`] whose rail screen readers call `name` (the row's label, already translated). +/// The rail and value field of [`slider`] without its label (for a row that draws its own); screen +/// readers call the rail `name` (the row's label, already translated). pub(super) fn slider_field_named( ui: &mut egui::Ui, d: &mut Dialog, @@ -511,14 +499,9 @@ pub(super) fn slider_field_named( } } -/// The rail of [`slider_field`] alone: dragging it sets the number `d.fields[key]` to a whole -/// number in `range` (a value past the range shows at its end until the rail moves). -#[allow(dead_code)] -pub(super) fn slider_rail(ui: &mut egui::Ui, d: &mut Dialog, key: &str, range: std::ops::RangeInclusive, track: &dyn Fn(f32) -> egui::Color32) { - slider_rail_named(ui, d, key, "", range, track); -} - -/// [`slider_rail`] that screen readers call `name` (already translated). +/// The rail of [`slider_field_named`] alone: dragging it sets the number `d.fields[key]` to a +/// whole number in `range` (a value past the range shows at its end until the rail moves); screen +/// readers call it `name` (already translated). pub(super) fn slider_rail_named( ui: &mut egui::Ui, d: &mut Dialog, From d5e44b321eab58f9c514794cd5e985ade1a3e58b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 21:22:38 -0700 Subject: [PATCH 078/208] Swatches: Alt-click on a swatch recolors the selected gradient stop With a gradient stop selected, an Alt-click on a color swatch painted the inactive Fill or Stroke, as it does with no stop selected. The reference app's Default keyboard shortcuts page lists "Apply swatch color to active (or selected) color stop: Alt-click swatch in the Swatches panel" for the Gradient panel. Now, when the active Fill or Stroke is a linear or radial gradient with a selected stop, an Alt-click on a color swatch or a tint swatch recolors that stop in one undo step, and the stop stays selected. The stop changes as a drop of the same swatch on the Gradient panel's ramp changes it (drop_color and dropped_link in panels/gradient.rs): it becomes fully opaque, and a global or spot color or a tint stays linked, so editing the swatch recolors the stop. With a freeform gradient, the selected point takes the color through paint.freeform.setPoint. With no stop or point selected, Alt-click paints the inactive Fill or Stroke as before. The Fill and Stroke chip popovers show the same swatches and behave the same. Docs: docs/control-protocol.md, docs/target-app-parity.md. --- crates/ui-egui/src/panels/gradient.rs | 17 ++++++ crates/ui-egui/src/panels/swatches.rs | 74 +++++++++++++++++++++++++-- docs/control-protocol.md | 4 +- docs/target-app-parity.md | 1 + 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 0c91c0089..194cf415c 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -82,6 +82,23 @@ pub(crate) fn dropped_link(app: &VectorcraftApp, params: &Value) -> Option<(Stri Some((base, tint)) } +/// Recolor the selected stop of the gradient behind the active proxy with `c`, as dropping the +/// paint of `params` on the stop does (fully opaque, linked by [`dropped_link`]), or the selected +/// point of a freeform gradient; the stop or point stays selected. Returns false, and changes +/// nothing, when the active paint is not a gradient or no stop or point is selected. +pub(crate) fn recolor_selected(app: &mut VectorcraftApp, c: Color, params: &Value) -> bool { + let Paint::Gradient(g) = active_paint(app) else { return false }; + if g.gradient.kind == GradientKind::Freeform { + let Some(i) = app.session.selected_freeform_point().filter(|i| *i < shown_points(&g).points.len()) else { return false }; + edit_point(app, "paint.freeform.setPoint", json!({ "index": i, "color": super::color_json(&c) })); + return true; + } + let Some(i) = app.session.selected_stop().filter(|i| *i < g.gradient.stops.len()) else { return false }; + let (v, i) = drop_color(&g.gradient, Some(i), 0.0, c, dropped_link(app, params)); + set_stops(app, &v, Some(i), Live::Released); + true +} + /// The stops after dragging stop `i` of `origin` to `offset`: moved, or with `copy` (Alt) a copy /// left there, or the colours swapped with stop `over` when the copy is dropped on it. Returns /// the stops and the stop to select. diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 878224283..e45af6b40 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -160,11 +160,18 @@ fn swatch_params(e: &Entry) -> Option { }) } -/// Apply a clicked swatch to the active proxy (Alt: the inactive one). +/// Apply a clicked swatch to the active proxy (Alt: the inactive one). An Alt-click on a color +/// swatch with a gradient stop selected recolors the stop as dropping the swatch on it does, +/// and with a freeform gradient point selected it recolors the point. fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) { - if let Some(params) = swatch_params(e) { - super::apply_click(app, ui, params); + let Some(params) = swatch_params(e) else { return }; + if super::alt_held(ui) + && let Some(c) = drag_of(e, vec![]).color() + && super::gradient::recolor_selected(app, c, ¶ms) + { + return; } + super::apply_click(app, ui, params); } /// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size). @@ -1378,6 +1385,67 @@ mod tests { assert_eq!(selected(&ctx), ["Grays"], "a plain click on a group selects it alone"); } + /// The hex colors of the stops of the selection's gradient fill, and its stroke's color. + fn stops_and_stroke(app: &VectorcraftApp) -> (Vec, String) { + let (fill, stroke) = crate::panels::current_paints(app); + let Paint::Gradient(g) = fill else { panic!("the fill is still a gradient: {fill:?}") }; + (g.gradient.stops.iter().map(|s| s.color.to_hex()).collect(), stroke.color().map(|c| c.to_hex()).unwrap_or_default()) + } + + #[test] + fn alt_click_recolors_the_selected_gradient_stop() { + let mut app = app(); + let ctx = context(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + app.run("paint.setStroke", json!({"color": "#000000"})).unwrap(); + let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]); + app.run("paint.setFill", json!({"gradient": {"stops": stops}})).unwrap(); + app.run("gradient.selectStop", json!({"index": 1})).unwrap(); + let undo = app.session.doc().unwrap().history.undo.len(); + click_tile(&mut app, &ctx, "Red", Modifiers::ALT, 0.0); + assert_eq!(stops_and_stroke(&app), (vec!["#ffffff".into(), "#ed1c24".into()], "#000000".into()), "the stop, not the stroke"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(app.session.selected_stop(), Some(1), "the stop stays selected"); + assert!(app.session.fill_active, "the Fill proxy stays in front"); + // With no stop selected, Alt-click paints the inactive proxy. + app.run("gradient.selectStop", json!({"index": null})).unwrap(); + click_tile(&mut app, &ctx, "Orange", Modifiers::ALT, 2.0); + let orange = app.session.doc().unwrap().doc.swatch("Orange").and_then(|w| w.paint.color()).unwrap().to_hex(); + assert_eq!(stops_and_stroke(&app), (vec!["#ffffff".into(), "#ed1c24".into()], orange)); + } + + #[test] + fn alt_click_links_the_selected_stop_to_a_global_swatch() { + let mut app = app(); + let ctx = context(); + app.run("swatch.edit", json!({"name": "Red", "global": true})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]); + app.run("paint.setFill", json!({"gradient": {"stops": stops}})).unwrap(); + app.run("gradient.selectStop", json!({"index": 0})).unwrap(); + click_tile(&mut app, &ctx, "Red", Modifiers::ALT, 0.0); + let Paint::Gradient(g) = crate::panels::current_paints(&app).0 else { panic!("a gradient fill") }; + assert_eq!(g.gradient.stops[0].swatch.as_deref(), Some("Red"), "linked as a drop on the ramp links it"); + app.run("swatch.edit", json!({"name": "Red", "color": "#00ff00"})).unwrap(); + assert_eq!(stops_and_stroke(&app).0, ["#00ff00", "#000000"], "editing the swatch recolors the stop"); + } + + #[test] + fn alt_click_recolors_the_selected_freeform_point() { + let mut app = app(); + let ctx = context(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + app.run("paint.setStroke", json!({"color": "#000000", "focus": false})).unwrap(); + app.run("paint.editGradient", json!({"kind": "freeform"})).unwrap(); + app.run("paint.freeform.selectPoint", json!({"index": 1})).unwrap(); + click_tile(&mut app, &ctx, "Red", Modifiers::ALT, 0.0); + let (fill, stroke) = crate::panels::current_paints(&app); + let Paint::Gradient(g) = fill else { panic!("still a gradient: {fill:?}") }; + assert_eq!(g.freeform.expect("placed points").points[1].color.to_hex(), "#ed1c24"); + assert_eq!(stroke.color().map(|c| c.to_hex()).as_deref(), Some("#000000"), "the stroke keeps its color"); + assert_eq!(app.session.selected_freeform_point(), Some(1), "the point stays selected"); + } + #[test] fn delete_asks_then_removes_the_selection_and_unlinks_art() { let mut app = app(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 461d4898b..d0b952e5f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -163,7 +163,9 @@ tool option `stop` (the tool to return to; `tool.setOption {key: "stop", value: samples the colour there into the selected stop (`paint.sampleColor {color, stop}`) and switches back. Dragging a swatch, a Fill/Stroke proxy or the panel's gradient thumbnail onto art runs `paint.setFill` / `paint.setStroke` (the active proxy) with the paint's params and the object's `ids`; a colour dropped on the panel's ramp adds or recolours -a stop. +a stop. Alt-clicking a color swatch in the Swatches panel recolors the selected stop as a drop on it does +(`paint.editGradient {stops}`), or the selected point of a freeform gradient (`paint.freeform.setPoint`); with no stop +or point selected, it paints the inactive Fill or Stroke. Freeform gradients: double-clicking a point with the Gradient tool opens the same `gradientStop` popover on the selected point (`paint.freeform.selectPoint`); its fields are `color` (hex), `opacity` and `spread` (percentages), applied by diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index de7b0e9c4..660659646 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -579,6 +579,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Sample Color from Screen:** an eyedropper button in the Color Picker and `ui.sampleScreenColor` (which can be given a key in Keyboard Shortcuts) pick a color anywhere on the screen with the system's sampler: `NSColorSampler` on macOS, the EyeDropper API in browsers that have it. The color becomes the fill or the stroke as one undo step, or the Color Picker's color, converted from display sRGB into the working RGB space. Windows, Linux and other browsers have no sampler yet; the button is gray there. - **Stroke visibility (#1253):** adding paint to a previously unpainted zero-width stroke restores the existing 1 pt default (`PaintDefaults::default`, `Appearance::set_stroke`), for the selected appearance item, the new-art defaults and Swap Fill/Stroke. Positive widths are kept; recoloring a painted zero-width stroke keeps its explicit zero. A real Eyedropper-to-new-shape sequence reproduces the zero-width inheritance. Five engine regressions cover creation, defaults, targeted items, preserved widths and one-step Undo/Redo (`crates/engine/tests/stroke_visibility.rs`). - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. + With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS. - **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. From a285efda46dbdf5a2c0d85e72c8f344c7e59e28d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 19:51:25 -0700 Subject: [PATCH 079/208] Print: Print, Print Preview and Print Setup take a print preset by name file.print, print.preview and print.setup took print settings only as settings, so an agent had to read a preset with print.presets.list and copy its settings into the call. They now take preset, the name of [Default] or of a saved print preset in any case. The preset's settings are used instead of the document's, settings applies over them, and print.setup saves them with the document as one undo step. An unknown name is a parameter error that lists the presets. The app's file.print with params passes preset on to the engine. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/print.rs | 32 +++++++--- crates/engine/src/tests_print.rs | 60 +++++++++++++++++++ crates/engine/src/tests_printpreview.rs | 14 +++++ crates/engine/tests/import_fuzz.rs | 3 +- .../src/dialogs/tests_print_presets.rs | 24 +++++++- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/print.rs | 9 +-- docs/mcp.md | 10 +++- docs/target-app-parity.md | 1 + 9 files changed, 137 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 4821edebd..68fd5cbda 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "Print Setup", [], None, - "{settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has (null keeps a value), as one undo step; no settings → {settings} (the current ones, defaults if never set up)", + "{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)", has_doc, setup ), @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Print Preview", [], None, - "{settings?: {…settings of print.setup} (over the document's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing", + "{preset?: name (as print.setup's: its settings instead of the document's), settings?: {…settings of print.setup} (over the document's or the preset's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing", has_doc, preview ), @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Print", [], Some("Cmd+P"), - "{settings?: {…settings of print.setup} (over the document's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device", + "{preset?: name (as print.setup's: its settings instead of the document's; the saved settings stay), settings?: {…settings of print.setup} (over the document's or the preset's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device", has_doc, print ), @@ -57,6 +57,23 @@ pub(crate) fn settings(cmd: &str, doc: &Document, p: &Value) -> Result Result { + let base = match p.get("preset") { + None | Some(Value::Null) => saved(doc), + Some(Value::String(name)) => { + let set = s.print_preset_settings(name).ok_or_else(|| { + let names: Vec = s.print_presets().into_iter().map(|q| q.name).collect(); + bad(cmd, format!("no print preset named `{name}` (presets: {})", names.join(", "))) + })?; + to_json(&set)? + } + Some(_) => return Err(bad(cmd, "preset must be the name of a print preset")), + }; + settings_over(cmd, base, p) +} + /// The settings `v` (JSON) with `p.settings` over them, checked. pub(crate) fn settings_over(cmd: &str, mut v: Value, p: &Value) -> Result { match p.get("settings") { @@ -75,8 +92,9 @@ pub(crate) fn to_json(set: &PrintSettings) -> Result { fn setup(s: &mut Session, p: &Value) -> Result { let doc = &s.doc()?.doc; - let set = to_json(&settings(SETUP, doc, p)?)?; - if p.get("settings").is_some_and(|v| !v.is_null()) && doc.print_setup.as_ref() != Some(&set) { + let set = to_json(&preset_or_saved(SETUP, s, doc, p)?)?; + let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null()); + if (given("settings") || given("preset")) && doc.print_setup.as_ref() != Some(&set) { let stored = set.clone(); s.edit("Print Setup", |d, _| { d.print_setup = Some(stored); @@ -89,7 +107,7 @@ fn setup(s: &mut Session, p: &Value) -> Result { fn preview(s: &mut Session, p: &Value) -> Result { const C: &str = "print.preview"; let doc = &s.doc()?.doc; - let set = settings(C, doc, p)?; + let set = preset_or_saved(C, s, doc, p)?; super::printadvanced::flattener(C, &set, &s.prefs.flattener_presets)?; let mut pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?; pv.warnings.extend(type_warnings(doc, set.print_layers, &areas(&pv))); @@ -148,7 +166,7 @@ fn print(s: &mut Session, p: &Value) -> Result { } let postscript = to_postscript(p)?; let doc = &s.doc()?.doc; - let mut set = settings(PRINT, doc, p)?; + let mut set = preset_or_saved(PRINT, s, doc, p)?; if postscript { // PostScript has no transparency: `flattenerPreset`, else the Advanced preset, else medium. let given = str_param(p, "flattenerPreset").filter(|n| !n.trim().is_empty()); diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs index 5094ed8ea..c92e3bf5a 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests_print.rs @@ -66,6 +66,66 @@ fn print_writes_the_saved_setup_with_overrides() { let _ = std::fs::remove_file(&path); } +/// A document with four 300 × 200 artboards, set up to print two copies, and a saved print preset +/// "Sheet" that prints artboard 4 only. +fn four_artboards_and_a_preset() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 4})).unwrap(); + s.execute("print.setup", &json!({"settings": {"copies": 2}})).unwrap(); + s.execute("print.presets.save", &json!({"name": "Sheet", "settings": {"artboards": "range", "range": "4"}})).unwrap(); + s +} + +#[test] +fn print_takes_a_print_preset_by_name() { + let mut s = four_artboards_and_a_preset(); + // The preset's settings stand in for the document's: one copy of artboard 4. + let (bytes, v) = print(&mut s, json!({"preset": "sheet"})); + assert_eq!((v["pages"].clone(), vectorcraft_pdf::info(&bytes, None).unwrap().pages.len()), (json!(1), 1)); + let (_, v) = print(&mut s, json!({"preset": "Sheet", "settings": {"copies": 3}})); + assert_eq!(v["pages"], 3, "settings apply over the preset's"); + let (_, v) = print(&mut s, json!({"preset": "[Default]"})); + assert_eq!(v["pages"], 4, "[Default]: every artboard, one copy"); + assert_eq!(s.execute("print.setup", &json!({})).unwrap()["settings"]["copies"], 2, "printing doesn't change the saved setup"); + // A PostScript file too. + let v = s.execute("file.print", &json!({"preset": "Sheet", "format": "postscript"})).unwrap(); + assert_eq!(v["pages"], 1); +} + +#[test] +fn an_unknown_print_preset_is_refused_with_the_presets() { + let mut s = four_artboards_and_a_preset(); + for c in ["file.print", "print.preview", "print.setup"] { + // Agents (MCP, the control channel) may pass `preset`. + s.check_params(c, &json!({"preset": "Sheet", "settings": {}})).unwrap(); + let e = s.execute(c, &json!({"preset": "Nope"})).unwrap_err(); + assert!(matches!(e, EngineError::BadParams { .. }), "{c}: {e}"); + assert!(e.to_string().contains("no print preset named `Nope` (presets: [Default], Sheet)"), "{c}: {e}"); + assert!(matches!(s.execute(c, &json!({"preset": 4})), Err(EngineError::BadParams { .. })), "{c}"); + } + assert_eq!(s.doc().unwrap().doc.print_setup.as_ref().unwrap()["copies"], 2); +} + +#[test] +fn setup_with_a_preset_stores_its_settings() { + let mut s = four_artboards_and_a_preset(); + let undo = s.doc().unwrap().history.undo.len(); + let v = s.execute("print.setup", &json!({"preset": "Sheet"})).unwrap(); + let got = (v["settings"]["copies"].clone(), v["settings"]["artboards"].clone(), v["settings"]["range"].clone()); + assert_eq!(got, (json!(1), json!("range"), json!("4"))); + let stored = s.doc().unwrap().doc.print_setup.clone().unwrap(); + assert_eq!(stored, v["settings"]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // The same preset again is no new step; settings apply over it. + s.execute("print.setup", &json!({"preset": "Sheet"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + let v = s.execute("print.setup", &json!({"preset": "Sheet", "settings": {"collate": false}})).unwrap(); + assert_eq!((v["settings"]["range"].clone(), v["settings"]["collate"].clone()), (json!("4"), json!(false))); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.print_setup.as_ref().unwrap()["copies"], 2); +} + #[test] fn preview_lists_pages_tiles_inks_and_warnings() { let (mut s, _) = session(); diff --git a/crates/engine/src/tests_printpreview.rs b/crates/engine/src/tests_printpreview.rs index ebe9be9e5..88ebdb627 100644 --- a/crates/engine/src/tests_printpreview.rs +++ b/crates/engine/src/tests_printpreview.rs @@ -52,6 +52,20 @@ fn pages_place_the_artboard_on_the_trim_box() { } } +#[test] +fn a_print_preset_lays_out_its_settings_with_settings_over_them() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 4})).unwrap(); + s.execute("print.setup", &json!({"settings": {"media": "a4"}})).unwrap(); + s.execute("print.presets.save", &json!({"name": "Sheet", "settings": {"artboards": "range", "range": "4"}})).unwrap(); + let v = s.execute("print.preview", &json!({"preset": "Sheet", "settings": {"artboards": "range", "range": "3"}})).unwrap(); + assert_eq!((v["pages"].clone(), v["sheets"][0]["artboard"].clone()), (json!(1), json!(2)), "{v}"); + assert_eq!(v["settings"]["media"], "letter", "the preset's paper, not the document's"); + let v = s.execute("print.preview", &json!({"preset": "Sheet"})).unwrap(); + assert_eq!((v["pages"].clone(), v["sheets"][0]["artboard"].clone()), (json!(1), json!(3))); + assert_eq!(s.doc().unwrap().doc.print_setup.as_ref().unwrap()["media"], "a4", "the preview keeps nothing"); +} + /// Point type at (`x`, `y`) in `family` → its id. fn type_in(s: &mut Session, x: f64, y: f64, family: &str) -> u64 { s.execute("text.create", &json!({"x": x, "y": y, "text": "Ab", "font": family, "style": "Regular"})).unwrap()["id"].as_u64().unwrap() diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index a6553a997..3d42d9835 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1932,12 +1932,13 @@ proptest! { // ---------- print presets files ---------- /// Import print presets from `data`, then lay out (and print, when short) the rich document with -/// each one imported. +/// each one imported, by its settings and by its name. fn print_presets(what: &str, data: &str) -> Result<(), TestCaseError> { survive_library(what, "print.presets.import", data, |s, r| { let list = no_panic(s.execute("print.presets.list", &json!({}))).unwrap_or_default(); for name in r["imported"].as_array().into_iter().flatten() { let Some(p) = list["presets"].as_array().into_iter().flatten().find(|p| &p["name"] == name) else { continue }; + let _ = no_panic(s.execute("print.preview", &json!({ "preset": name }))); let settings = json!({ "settings": p["settings"] }); let pages = no_panic(s.execute("print.preview", &settings)).ok().and_then(|v| v["pages"].as_u64()); let _ = no_panic(s.execute("print.setup", &settings)); diff --git a/crates/ui-egui/src/dialogs/tests_print_presets.rs b/crates/ui-egui/src/dialogs/tests_print_presets.rs index ae4848fb3..ce70f72db 100644 --- a/crates/ui-egui/src/dialogs/tests_print_presets.rs +++ b/crates/ui-egui/src/dialogs/tests_print_presets.rs @@ -1,5 +1,9 @@ //! Print presets in the UI: the Print dialog's preset list and Save Preset…, and Edit → Print -//! Presets (New…, Edit…, Delete, Import…, Export…) with its preset editor. +//! Presets (New…, Edit…, Delete, Import…, Export…) with its preset editor, and `file.print` with +//! a preset's name. + +use std::cell::RefCell; +use std::rc::Rc; use serde_json::{Value, json}; use vectorcraft_engine::Session; @@ -7,7 +11,7 @@ use vectorcraft_engine::cmd::printpresets::DEFAULT_PRESET; use super::print::{KIND, PRESET_KIND, SECTIONS}; use super::print_presets::{self, Action}; -use crate::{VectorcraftApp, theme}; +use crate::{Services, VectorcraftApp, theme}; fn frame(app: &mut VectorcraftApp) { let ctx = egui::Context::default(); @@ -77,6 +81,22 @@ fn the_print_dialog_saves_and_loads_presets() { assert_eq!(app.session.prefs.print_presets.len(), 1); } +#[test] +fn file_print_with_params_prints_a_preset_by_name() { + let written: Rc>>> = Rc::default(); + let w = written.clone(); + let write = move |_: &str, b: &[u8]| { + w.borrow_mut().push(b.to_vec()); + Ok(()) + }; + let mut app = VectorcraftApp::new(Session::new(), Services { write: Some(Box::new(write)), ..Default::default() }); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": 4})).unwrap(); + app.run("print.presets.save", json!({"name": "Sheet", "settings": {"artboards": "range", "range": "4"}})).unwrap(); + let r = app.run("file.print", json!({"path": "/tmp/sheet.pdf", "preset": "Sheet"})).unwrap(); + assert_eq!(r["pages"], 1, "{r}"); + assert_eq!(vectorcraft_pdf::info(&written.borrow()[0], None).unwrap().pages.len(), 1); +} + #[test] fn the_manager_adds_edits_and_deletes_presets_through_the_editor() { // Edit → Print Presets works without a document too. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 225cfb0cc..b7d32a066 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -812,7 +812,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.print", "Print…", "Cmd+P", - "{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {settings? (over the document's), printer? (print.printers name; \"\" the default), toFile?, path?, format?: pdf|postscript, level?, flattenerPreset? (as engine file.print)} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}; a PostScript file (format postscript, or a .ps path) is always saved that way", + "{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {preset? (a print preset's name: its settings instead of the document's), settings? (over the document's or the preset's), printer? (print.printers name; \"\" the default), toFile?, path?, format?: pdf|postscript, level?, flattenerPreset? (as engine file.print)} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}; a PostScript file (format postscript, or a .ps path) is always saved that way", ), ( "print.printers", diff --git a/crates/ui-egui/src/print.rs b/crates/ui-egui/src/print.rs index 12e6e73a8..0fcb163da 100644 --- a/crates/ui-egui/src/print.rs +++ b/crates/ui-egui/src/print.rs @@ -58,9 +58,10 @@ pub fn printers(app: &mut VectorcraftApp) -> Value { json!({ "printers": printers, "service": app.services.print.is_some(), "setup": setup }) } -/// `file.print` with settings: print the active document with `settings` (over the document's) -/// on `printer` (default: the system's default), or save the job as a PDF at `path` (else a picked -/// file, the web downloads it) with `toFile`, a `path` or no print service. A PostScript file +/// `file.print` with settings: print the active document with `settings` (over the document's, or +/// over those of the print preset `preset` names) on `printer` (default: the system's default), or +/// save the job as a PDF at `path` (else a picked file, the web downloads it) with `toFile`, a +/// `path` or no print service. A PostScript file /// (`format: "postscript"` or a `.ps` path, at `level`, flattened with `flattenerPreset`) is saved /// the same way. pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { @@ -68,7 +69,7 @@ pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { let postscript = s("format").as_deref() == Some("postscript") || s("path").is_some_and(|path| fileio::extension(&path) == "ps"); let to_file = p.get("toFile").and_then(Value::as_bool).unwrap_or(false) || s("path").is_some() || postscript; let mut q = json!({}); - for k in ["settings", "format", "level", "flattenerPreset"] { + for k in ["preset", "settings", "format", "level", "flattenerPreset"] { if let Some(v) = p.get(k).filter(|v| !v.is_null()) { q[k] = v.clone(); } diff --git a/docs/mcp.md b/docs/mcp.md index a1b97ef2d..a9a6f2e29 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2749,7 +2749,8 @@ answers what it would print without writing it: `{pages (copies included), sheet height, orientation, scale}] (one copy, in order), tiles: [{artboard, columns, rows, printed, tiles}] (document space), inks: [{name, spot, print, frequency, angle}], warnings, settings}`. `print.setup {settings?}` saves the settings with the document (one undo step, kept in the native file as `print_setup`); both other commands start from -the saved settings, with `settings` over them (`null` keeps a value). `list_commands` with filter `print.setup` +the saved settings, or from a print preset's with `preset` (see Print presets), with `settings` over them (`null` keeps +a value). `list_commands` with filter `print.setup` documents every field: - General: `copies` (1–999), `collate`, `reverse`; `artboards: all|range|ignore` with `range: "1-3, 5"` (ignore: all @@ -3017,11 +3018,14 @@ preset?, settings?}` creates or changes a saved preset (starting from its own se `settings` over them) and `print.presets.delete {name}` deletes one; `[Default]` can be neither changed nor deleted. `print.presets.export {names?, path?}` writes them as a `.vcprintpresets` JSON file (without `path` it returns `data`), and `print.presets.import {path? | data? | dataBase64?, replace?}` adds a file's presets (a name in use gets a number -unless `replace`). Saved presets live with the preferences. To print with a preset, pass its `settings` to -`print.setup`, `print.preview` or `file.print`. +unless `replace`). Saved presets live with the preferences. `print.setup`, `print.preview` and `file.print` take +`preset`, the name of `[Default]` or of a saved preset in any case: the preset's settings are used instead of the +document's, with `settings` over them, and `print.setup {preset}` saves them with the document. An unknown name is +refused with the list of presets. ```json {"name":"run_command","arguments":{"command":"print.presets.save","params":{"name":"Posters","settings":{"scaling":"tileImageable","overlap":18,"marks":{"trim":true}}}}} +{"name":"run_command","arguments":{"command":"file.print","params":{"preset":"Posters","path":"/tmp/posters.pdf"}}} ``` ## EPS and PostScript import diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 660659646..4cc16ff79 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -622,6 +622,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. - Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft; Windows 7 retains WMI and the installed PDF viewer. The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged. - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one; the title is not changed (`crates/pdf/src/tests_file_info.rs`, `crates/engine/src/tests_fileinfo.rs`). + - `file.print`, `print.preview` and `print.setup` take a print preset by name (`preset`: `[Default]` or a saved preset, in any case): its settings are used instead of the document's, with `settings` over them, `print.setup` saves them with the document, and an unknown name is refused with the list of presets. - Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. - Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected. From 5bdd9d539f1bde9dcbb04b8f74d44b5f63d2ee2c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 18:32:28 -0700 Subject: [PATCH 080/208] =?UTF-8?q?Variables:=20Import=20Data=20Sets?= =?UTF-8?q?=E2=80=A6=20reads=20tab-separated=20files=20and=20files=20witho?= =?UTF-8?q?ut=20a=20name=20column?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dataset.import read only commas between cells and refused a file whose first header cell wasn't `name`. A value that didn't match its variable's kind, and a repeated data set name, were reported with the row's record number, which is not the file line after a blank line or a quoted line break. A data set name over 255 characters gave no line. - When the header row, read with tabs between cells, has a tab and no comma outside quotes, tabs separate the cells. A quoted cell may then hold tabs and commas. - Without a `name` column every header cell is a variable, and the rows take the first free names Data Set 1, Data Set 2 and so on, as Capture Data Set and blank name cells do. A first header cell `name`, in any case, still holds the data set names. - Value errors, a repeated data set name and a name over 255 characters give the file line the row starts on. - The Variables panel's Import Data Sets… dialog also lists .tsv files. - import_fuzz.rs imports random text, random bytes and mutated exports, comma- and tab-separated, into a document with bound text, visibility and graph variables, selects up to three of the imported data sets in turn, and exports every data set. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/variablecmds.rs | 115 ++++++++++++++++++------- crates/engine/src/tests_variables.rs | 66 +++++++++++++- crates/engine/tests/import_fuzz.rs | 63 ++++++++++++++ crates/ui-egui/src/panels/variables.rs | 4 +- docs/mcp.md | 5 +- docs/target-app-parity.md | 1 + 6 files changed, 219 insertions(+), 35 deletions(-) diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index aae552784..e57a5049e 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -5,8 +5,9 @@ //! variable, or a graph root to a `graph` variable (CSV data), put a row of values in //! each dataset, and `dataset.select` swaps the whole document to that row in one undo //! step. `dataset.import` / `dataset.export` move those rows as CSV (header: `name` -//! then variable names). Agents reach every command through `run_command`, like all -//! engine commands. +//! then variable names); import also reads tab-separated files and files without the +//! `name` column. Agents reach every command through `run_command`, like all engine +//! commands. use std::collections::{BTreeMap, BTreeSet}; @@ -535,16 +536,61 @@ fn datasets_csv_bytes(p: &Value, cmd: &str) -> Result> { Ok(bytes) } -/// Parse a datasets CSV into rows of cells (no header interpretation yet): commas between cells, -/// `"` quotes a cell (doubled inside), which may then hold commas and line breaks, as -/// [`csv_escape`] writes them. An unquoted cell is trimmed. A quoted cell keeps what its quotes -/// hold as written, spaces included, and drops white space after the closing quote at the end -/// of the cell. Blank lines are skipped. -fn parse_datasets_csv(text: &str, cmd: &str) -> Result>> { +/// A row of a datasets CSV: the file line it starts on, and its cells. +type CsvRow = (usize, Vec); + +/// Whether a datasets CSV separates its cells with tabs: its header row (the first line that +/// isn't blank), read with tabs between cells, has a tab outside quotes and no comma outside +/// quotes. +fn tab_separated(text: &str) -> bool { + // `start`: nothing but `\r` read yet in this cell. `blank`: no cell on this line holds + // anything but white space yet. + let (mut in_quotes, mut start, mut blank, mut tab) = (false, true, true, false); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' if in_quotes => { + if chars.peek() == Some(&'"') { + chars.next(); + blank = false; + } else { + in_quotes = false; + } + } + '"' if start => in_quotes = true, + c if in_quotes => blank &= c.is_whitespace(), + ',' => return false, + '\t' => { + tab = true; + start = true; + continue; + } + '\r' => continue, + '\n' if blank => { + tab = false; + start = true; + continue; + } + '\n' => return tab, + c => blank &= c.is_whitespace(), + } + start = false; + } + tab +} + +/// Parse a datasets CSV into rows of cells (no header interpretation yet). Cells are separated +/// by commas, or by tabs when [`tab_separated`] says so. `"` quotes a cell (doubled inside), +/// which may then hold separators and line breaks, as [`csv_escape`] writes them. An unquoted +/// cell is trimmed. A quoted cell keeps what its quotes hold as written, spaces included, and +/// drops white space after the closing quote at the end of the cell. Blank lines are skipped. +fn parse_datasets_csv(text: &str, cmd: &str) -> Result> { let text = text.strip_prefix('\u{feff}').unwrap_or(text); + let sep = if tab_separated(text) { '\t' } else { ',' }; let (mut rows, mut row, mut cur) = (Vec::new(), Vec::::new(), String::new()); - // `closed`: where the closing quote of a quoted cell falls in `cur`. - let (mut in_quotes, mut closed, mut line) = (false, None, 1usize); + // `closed`: where the closing quote of a quoted cell falls in `cur`. `start`: the line the + // current row starts on. + let (mut in_quotes, mut closed, mut line, mut start) = (false, None, 1usize, 1usize); let mut chars = text.chars().peekable(); loop { let c = chars.next(); @@ -559,12 +605,12 @@ fn parse_datasets_csv(text: &str, cmd: &str) -> Result>> { } } Some('"') if cur.is_empty() => in_quotes = true, - Some(',') if !in_quotes => row.push(end_csv_cell(&mut cur, &mut closed)), + Some(ch) if ch == sep && !in_quotes => row.push(end_csv_cell(&mut cur, &mut closed)), Some('\r') if !in_quotes => {} Some('\n') | None if !in_quotes => { row.push(end_csv_cell(&mut cur, &mut closed)); if row.iter().any(|c| !c.trim().is_empty()) { - rows.push(std::mem::take(&mut row)); + rows.push((start, std::mem::take(&mut row))); // +1 for the header. if rows.len() > MAX_CSV_ROWS + 1 { return Err(bad(cmd, format!("more than {MAX_CSV_ROWS} data rows"))); @@ -575,6 +621,7 @@ fn parse_datasets_csv(text: &str, cmd: &str) -> Result>> { return Ok(rows); } line += 1; + start = line; } None => return Err(bad(cmd, format!("line {line}: unclosed quote"))), Some(c) => { @@ -659,27 +706,27 @@ fn datasets_to_csv(vars: &Variables) -> String { out } -/// Import datasets from CSV: first column `name`, remaining headers are variable names. -/// Existing rows of the same name are replaced; new names are appended. One undo step. +/// Import datasets from CSV. A first header cell `name` makes the first column the dataset +/// names; the other headers are variable names. Without it, every header is a variable name +/// and the rows take the first free names "Data Set 1", "Data Set 2" and so on. Existing rows +/// of the same name are replaced; new names are appended. One undo step. fn dataset_import(s: &mut Session, p: &Value) -> Result { const C: &str = "dataset.import"; let bytes = datasets_csv_bytes(p, C)?; let text = std::str::from_utf8(&bytes).map_err(|_| bad(C, "CSV is not UTF-8"))?; let rows = parse_datasets_csv(text, C)?; - let Some(header) = rows.first() else { + let Some((_, header)) = rows.first() else { return Err(bad(C, "CSV is empty")); }; - if header.first().map(|h| h.trim().eq_ignore_ascii_case("name")) != Some(true) { - return Err(bad(C, "first header cell must be `name`")); - } + let name_column = header.first().is_some_and(|h| h.trim().eq_ignore_ascii_case("name")); let (kinds, existing_names) = { let st = s.doc()?; let kinds: BTreeMap = st.doc.variables.variables.iter().map(|v| (v.name.clone(), v.kind)).collect(); - let existing: Vec = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect(); + let existing: BTreeSet = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect(); (kinds, existing) }; let mut columns: Vec<(usize, String, VariableKind)> = Vec::new(); - for (i, h) in header.iter().enumerate().skip(1) { + for (i, h) in header.iter().enumerate().skip(usize::from(name_column)) { let name = h.trim(); if name.is_empty() { return Err(bad(C, format!("empty header in column {}", i + 1))); @@ -691,19 +738,25 @@ fn dataset_import(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "CSV has a header but no data rows")); } let mut parsed: Vec = Vec::new(); - for (r_i, row) in rows.iter().enumerate().skip(1) { - let line = r_i + 1; - let raw_name = row.first().map(|c| c.trim()).unwrap_or(""); + // The names of the rows read so far, and the last number given to a row without a name. + let (mut seen, mut number) = (BTreeSet::new(), 0usize); + for &(line, ref row) in rows.iter().skip(1) { + let raw_name = if name_column { row.first().map_or("", |c| c.trim()) } else { "" }; let name = if raw_name.is_empty() { - // Same numbering as Capture Data Set when a row leaves the name blank. - (1..) - .map(|n| format!("Data Set {n}")) - .find(|n| parsed.iter().all(|d| d.name != *n) && !existing_names.iter().any(|e| e == n)) - .unwrap_or_else(|| format!("Data Set {line}")) + // Same numbering as Capture Data Set when a row leaves the name blank or the file has + // no name column. The search starts after the last number given. + let (n, name) = (number + 1..) + .map(|n| (n, format!("Data Set {n}"))) + .find(|(_, n)| !seen.contains(n) && !existing_names.contains(n)) + .unwrap_or_else(|| (line, format!("Data Set {line}"))); + number = n; + name + } else if raw_name.chars().count() > MAX_NAME { + return Err(bad(C, format!("line {line}: `name` is longer than {MAX_NAME} characters"))); } else { - named(Some(raw_name), "name", C)? + raw_name.to_string() }; - if parsed.iter().any(|d| d.name == name) { + if !seen.insert(name.clone()) { return Err(bad(C, format!("line {line}: dataset `{name}` appears twice"))); } let mut values = BTreeMap::new(); @@ -937,7 +990,7 @@ pub fn specs() -> Vec { "Import Data Sets…", ["Window", "Variables"], None, - "{path? | data? | dataBase64? | csv?} load datasets from CSV (header: name, then variable names; one row per dataset). Rows whose name already exists are replaced → {imported, updated, names}", + "{path? | data? | dataBase64? | csv?} load datasets from CSV, comma- or tab-separated (header: name, then variable names; one row per dataset; without the name column every header is a variable and the rows take the first free names Data Set 1, 2…). Rows whose name already exists are replaced → {imported, updated, names}", has_doc, dataset_import ), diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests_variables.rs index b455980c0..b6824f497 100644 --- a/crates/engine/src/tests_variables.rs +++ b/crates/engine/src/tests_variables.rs @@ -948,6 +948,70 @@ fn datasets_csv_keeps_spaces_in_quoted_cells() { assert_eq!(value("C"), text_value(" quoted ")); } +/// A header row with a tab and no comma outside quotes makes tabs the separator; a quoted cell +/// may then hold tabs and commas. A header row with a comma outside quotes keeps commas, even +/// when a cell holds a tab. A tab-separated header with an unclosed quote fails with the reason. +#[test] +fn datasets_csv_reads_tab_separated_files() { + let mut s = session(); + s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + s.execute("variable.define", &json!({"name": "Last, First", "kind": "text"})).unwrap(); + let tsv = "name\tTitle\tShow\r\nA\tTwo words\ttrue\r\nB\t\"a, b\tc\"\tfalse\r\n"; + let imp = s.execute("dataset.import", &json!({"csv": tsv})).unwrap(); + assert_eq!(imp["names"], json!(["A", "B"])); + s.execute("dataset.import", &json!({"csv": "name,Title\nC,one\ttwo"})).unwrap(); + s.execute("dataset.import", &json!({"csv": "name\t\"Last, First\"\nD\tSmith, Jo"})).unwrap(); + let value = |name: &str, var: &str| s.doc().unwrap().doc.variables.dataset(name).and_then(|d| d.values.get(var)).cloned(); + let text_value = |v: &str| Some(vectorcraft_doc::DataValue::Text(v.into())); + assert_eq!(value("A", "Title"), text_value("Two words")); + assert_eq!(value("A", "Show"), Some(vectorcraft_doc::DataValue::Visible(true))); + assert_eq!(value("B", "Title"), text_value("a, b\tc")); + assert_eq!(value("B", "Show"), Some(vectorcraft_doc::DataValue::Visible(false))); + assert_eq!(value("C", "Title"), text_value("one\ttwo")); + assert_eq!(value("D", "Last, First"), text_value("Smith, Jo")); + let unclosed = s.execute("dataset.import", &json!({"csv": "name\tTitle\t\"Show\nA\tx\ttrue"})).unwrap_err().to_string(); + assert!(unclosed.contains("unclosed quote"), "{unclosed}"); +} + +/// Without a `name` column every header cell is a variable, and the rows take the first free +/// names Data Set 1, 2 and so on. A first header cell `name`, in any case, still holds the names. +#[test] +fn datasets_csv_without_a_name_column_numbers_the_rows() { + let mut s = session(); + s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); + s.execute("dataset.new", &json!({"name": "Data Set 2", "values": {"Title": "kept"}})).unwrap(); + let imp = s.execute("dataset.import", &json!({"csv": "Title\nx\ny\n"})).unwrap(); + assert_eq!((imp["imported"].as_u64(), imp["updated"].as_u64()), (Some(2), Some(0))); + assert_eq!(imp["names"], json!(["Data Set 1", "Data Set 3"])); + let value = |s: &Session, name: &str| s.doc().unwrap().doc.variables.dataset(name).and_then(|d| d.values.get("Title")).cloned(); + let text_value = |v: &str| Some(vectorcraft_doc::DataValue::Text(v.into())); + assert_eq!(value(&s, "Data Set 1"), text_value("x")); + assert_eq!(value(&s, "Data Set 2"), text_value("kept")); + assert_eq!(value(&s, "Data Set 3"), text_value("y")); + let named = s.execute("dataset.import", &json!({"csv": "NAME,Title\nData Set 1,z"})).unwrap(); + assert_eq!((named["imported"].as_u64(), named["updated"].as_u64()), (Some(0), Some(1))); + assert_eq!(value(&s, "Data Set 1"), text_value("z")); +} + +/// An error about a row's values or name gives the file line the row starts on, counting blank +/// lines and line breaks inside quoted cells. +#[test] +fn datasets_csv_row_errors_name_the_file_line() { + let mut s = session(); + s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + let error = |s: &mut Session, csv: &str| s.execute("dataset.import", &json!({"csv": csv})).unwrap_err().to_string(); + let blank_line = error(&mut s, "name,Show\n\nX,maybe"); + assert!(blank_line.contains("line 3:"), "{blank_line}"); + let line_break = error(&mut s, "name,Title,Show\nA,\"one\ntwo\",true\nB,x,maybe"); + assert!(line_break.contains("line 4:"), "{line_break}"); + let twice = error(&mut s, "name,Title\nA,x\n\nA,y"); + assert!(twice.contains("line 4:"), "{twice}"); + let long = error(&mut s, &format!("name,Title\n\n{},x", "n".repeat(256))); + assert!(long.contains("line 3:") && long.contains("longer than 255"), "{long}"); +} + /// Import / export datasets as CSV: header `name` then variables; round-trip keeps values. #[test] fn datasets_csv_round_trips_and_rejects_bad_input() { @@ -989,7 +1053,7 @@ fn datasets_csv_round_trips_and_rejects_bad_input() { assert_eq!((again["imported"].as_u64(), again["updated"].as_u64()), (Some(0), Some(2))); assert!(s.execute("dataset.import", &json!({"csv": "name,Ghost\nX,y"})).is_err()); assert!(s.execute("dataset.import", &json!({"csv": "name,Show\nX,maybe"})).is_err()); - assert!(s.execute("dataset.import", &json!({"csv": "Title,Name\nX,y"})).is_err(), "first column must be name"); + assert!(s.execute("dataset.import", &json!({"csv": "Title,Name\nX,y"})).is_err(), "no variable named Title"); let out = dir.file("sets.csv"); s.execute("dataset.export", &json!({"path": out})).unwrap(); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 3d42d9835..faac93f82 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1157,6 +1157,69 @@ proptest! { } } +// ---------- data set CSV files ---------- + +/// A document with a text, a visibility and a graph variable, each bound to an object, and two +/// data sets. +fn merge_session() -> vectorcraft_engine::Session { + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = |r: Value| r["id"].as_u64().unwrap(); + let t = id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One"})).unwrap()); + let r = id(s.execute("shape.rectangle", &json!({"x": 10, "y": 40, "width": 50, "height": 40})).unwrap()); + let graph = json!({"type": "column", "x": 80, "y": 10, "width": 100, "height": 80, "csv": ",Sales\nA,1\nB,2"}); + let g = id(s.execute("graph.create", &graph).unwrap()); + for (name, kind, object) in [("Title", "text", t), ("Show", "visibility", r), ("Chart", "graph", g)] { + s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap(); + s.execute("variable.bind", &json!({"variable": name, "ids": [object]})).unwrap(); + } + s.execute("dataset.new", &json!({"name": "A", "values": {"Title": "Two, \"quoted\"\nlines", "Show": true, "Chart": ",Q\nX,3"}})).unwrap(); + s.execute("dataset.new", &json!({"name": "B", "values": {"Title": " padded ", "Show": false}})).unwrap(); + s +} + +/// [`merge_session`]'s data sets exported as CSV, made once. +fn saved_datasets() -> String { + static TEXT: std::sync::OnceLock = std::sync::OnceLock::new(); + TEXT.get_or_init(|| merge_session().execute("dataset.export", &json!({})).unwrap()["csv"].as_str().unwrap().to_string()).clone() +} + +/// `dataset.import` with `params` on [`merge_session`]'s document, the first three data sets it +/// read selected in turn and every data set exported again; the document then renders and exports. +fn datasets(what: &str, params: Value) -> Result<(), TestCaseError> { + survive(what, || { + let mut s = merge_session(); + if let Ok(r) = no_panic(s.execute("dataset.import", ¶ms)) { + for name in r["names"].as_array().into_iter().flatten().take(3) { + let _ = no_panic(s.execute("dataset.select", &json!({"name": name}))); + } + } + let _ = no_panic(s.execute("dataset.export", &json!({}))); + Some((*s.doc().ok()?.doc).clone()) + }) +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn dataset_csv_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "name,Title,Show,Chart\n", "Title\tShow\tChart\n", "name\tTitle\n", "Show,Chart\n"])) { + datasets("data set CSV garbage", json!({"csv": format!("{head}{s}")}))?; + } + + #[test] + fn dataset_csv_bytes_never_panic(bytes in prop::collection::vec(any::(), 0..300)) { + datasets("data set CSV bytes", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)}))?; + } + + #[test] + fn mutated_dataset_csvs_never_panic(cut in 0usize..200, edits in prop::collection::vec(arb_edit(), 0..10), tabs in any::()) { + let text = saved_datasets(); + let text = if tabs { text.replace(',', "\t") } else { text }; + datasets("mutated data set CSV", json!({"csv": mutate_text(&text, cut, &edits)}))?; + } +} + // ---------- shortcut sets ---------- /// Shortcut set `bytes` read with `shortcutSet.read`, with import matches saved to annotate it: an diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index 48d0c24ba..a256e10af 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -319,9 +319,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } } -/// Variables panel: Import Data Sets… — pick a CSV and run `dataset.import`. +/// Variables panel: Import Data Sets… opens a file dialog for a comma- or tab-separated file and runs `dataset.import` on the chosen file. fn import_datasets(app: &mut VectorcraftApp) { - let pick = crate::FilePick { filters: vec![("CSV", &["csv", "txt"])], ..Default::default() }; + let pick = crate::FilePick { filters: vec![("CSV", &["csv", "tsv", "txt"])], ..Default::default() }; let Some(path) = crate::picks::open(app, &pick) else { return }; if let Err(e) = app.run("dataset.import", json!({"path": path})) { app.status(e); diff --git a/docs/mcp.md b/docs/mcp.md index a9a6f2e29..c62d78b51 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1083,7 +1083,10 @@ already walks Image Trace. Short agent entry points: - Data sets: `dataset.list`, `dataset.new {name, values?}`, `dataset.set {name, values?}`, `dataset.rename`, `dataset.delete`, `dataset.capture`, `dataset.update`, `dataset.select {name}`, `dataset.next` / `dataset.prev` (each `dataset.select` / next / prev is one undo step over every bound object), `dataset.import {path|data|dataBase64|csv}` / - `dataset.export {path?}` (CSV: `name`, then one column per variable). + `dataset.export {path?}` (CSV: `name`, then one column per variable). `dataset.import` also reads tab-separated + files, and files whose first header cell isn't `name`: every header cell is then a variable, and the rows take the + first free names `Data Set 1`, `Data Set 2` and so on. An import error about a row's values or name gives the file + line the row starts on. - Batch: `action.batch {commands, source?: folder|datasets, folder?, destination?: none|saveAndClose|folder, destinationFolder?, overrideOpen?, overrideSave?, overrideExport?, includeSubdirs?, stopOnError?}` plays an action on every file in a folder or every data set (the Actions panel menu's Batch…). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4cc16ff79..722302b45 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -415,6 +415,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: `dataset.select`, `dataset.next` and `dataset.prev` name the area type and type on a path whose text they rewrote and that doesn't fit, in the reply's `overflow` key, as `text.setText` does. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Variables (data merge): text, visibility and image variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility/Image Dynamic, Capture/Update Data Set, CSV import/export (`dataset.import` / `dataset.export`), and a row in italics when an edit leaves the art not matching it; an image dataset value is a file path and apply keeps the object's bounds; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI. + - Variables: Import Data Sets… (`dataset.import`) reads tab-separated files as well as comma-separated ones, and files without a `name` column, whose rows take the first free names Data Set 1, Data Set 2 and so on. An import error about a row's values or name gives the file line the row starts on. - JSON-lines control channel with real egui pointer and keyboard injection. - Synthetic input validates complete gestures before queue mutation: at most 256 drag steps, 16 clicks and 1,024 pending events; up to 64 control requests per frame, with later requests retained for the next frame (#1709). - The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s. From 5a3e833ec887f898c670c58069fcf04dd51c7a91 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 19:26:28 -0700 Subject: [PATCH 081/208] Type: a click in a Devanagari syllable goes to its nearer end, and shaping is tested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A syllable drawn with several glyphs that share one cluster, such as a pre-base i matra and its consonant (कि), had a caret stop at the edge of each glyph. A click just right of the matra, in the left half of the syllable, put the caret after the syllable. byte_in_line now takes one extent for each cluster, and type on a path compares the click with the middle of the cluster, from the start of its first glyph to the end of its last. New tests with an installed OpenType Devanagari font check that क्ष forms one conjunct glyph, that र्क draws the reph after its base, that कि draws the matra before its consonant, that Ka with a nukta matches the precomposed क़, and that each syllable is one cluster with the arrow keys stopping at its ends. They print SKIPPED when no such font is installed. Docs: docs/localization-parity.md (Hindi row), docs/target-app-parity.md. --- crates/text/src/lib.rs | 37 ++++--- crates/text/src/tests_devanagari.rs | 158 ++++++++++++++++++++++++++++ docs/localization-parity.md | 2 +- docs/target-app-parity.md | 1 + 4 files changed, 185 insertions(+), 13 deletions(-) create mode 100644 crates/text/src/tests_devanagari.rs diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index e65c1f79f..70e210ed3 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -304,16 +304,20 @@ pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) { /// Byte offset nearest to the text-space point `p` (for clicking/dragging with the Type tool). pub fn hit_byte(layout: &TextLayout, p: Point) -> usize { if layout.on_path { - let best = layout.glyphs.iter().min_by(|a, b| { + let best = layout.glyphs.iter().enumerate().min_by(|(_, a), (_, b)| { let ca = a.origin + dir(a.angle) * (a.advance * 0.5); let cb = b.origin + dir(b.angle) * (b.advance * 0.5); (p - ca).hypot2().total_cmp(&(p - cb).hypot2()) }); - return match best { - Some(g) if ((p - g.origin).dot(dir(g.angle)) < g.advance * 0.5) != g.rtl => g.byte, - Some(g) => g.byte + g.len, - None => layout.lines.first().map_or(0, |l| l.start), - }; + let Some((i, g)) = best else { return layout.lines.first().map_or(0, |l| l.start) }; + // `first` and `last` are the first and last glyphs of the cluster. A click before the middle of + // the cluster, measured along the first glyph's direction, goes to the cluster's start. + let same = |o: &&PositionedGlyph| o.byte == g.byte && o.len == g.len; + let first = layout.glyphs[..=i].iter().rev().take_while(same).last().unwrap_or(g); + let last = layout.glyphs[i..].iter().take_while(same).last().unwrap_or(g); + let along = dir(first.angle); + let span = (last.origin + dir(last.angle) * last.advance - first.origin).dot(along); + return if ((p - first.origin).dot(along) < span * 0.5) != g.rtl { g.byte } else { g.byte + g.len }; } let p = layout.logical_point(p); // Nearest line band (vertical distance first, then distance to the line's column). @@ -349,13 +353,20 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize { pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize { let Some(line) = layout.lines.get(li) else { return 0 }; let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; - let nearest = glyphs + // A cluster drawn with several adjacent glyphs (a Devanagari pre-base matra and its consonant, + // a consonant and its reph) has one caret stop at each end of its glyphs taken together. + let mut clusters: Vec<(f64, f64, &PositionedGlyph)> = Vec::with_capacity(glyphs.len()); + for g in glyphs { + let left = layout.logical_point(g.origin).x; + let right = left + g.advance; + match clusters.last_mut() { + Some((l, r, c)) if c.byte == g.byte && c.len == g.len => (*l, *r) = (l.min(left), r.max(right)), + _ => clusters.push((left, right, g)), + } + } + let nearest = clusters .iter() - .flat_map(|g| { - let left = layout.logical_point(g.origin).x; - let right = left + g.advance; - if g.rtl { [(right, g.byte), (left, g.byte + g.len)] } else { [(left, g.byte), (right, g.byte + g.len)] } - }) + .flat_map(|&(left, right, g)| if g.rtl { [(right, g.byte), (left, g.byte + g.len)] } else { [(left, g.byte), (right, g.byte + g.len)] }) .min_by(|a, b| (a.0 - x).abs().total_cmp(&(b.0 - x).abs())); nearest.map_or(line.start, |(_, byte)| byte) } @@ -518,6 +529,8 @@ mod tests_bidi; #[cfg(test)] mod tests_combos; #[cfg(test)] +mod tests_devanagari; +#[cfg(test)] mod tests_embed; #[cfg(test)] mod tests_fit; diff --git a/crates/text/src/tests_devanagari.rs b/crates/text/src/tests_devanagari.rs new file mode 100644 index 000000000..de238ee05 --- /dev/null +++ b/crates/text/src/tests_devanagari.rs @@ -0,0 +1,158 @@ +//! Devanagari shaping: conjuncts, reph, pre-base matras and nukta forms, with their source clusters +//! and caret stops. The expected forms come from Microsoft's OpenType Devanagari shaping specification +//! (https://learn.microsoft.com/en-us/typography/script-development/devanagari) and the Unicode +//! Standard's Devanagari section (https://www.unicode.org/versions/latest/core-spec/chapter-12/). +//! No font is committed: the tests use an installed face and print SKIPPED when none is found. +use super::*; +use kurbo::{BezPath, Point}; +use std::sync::Arc; +use vectorcraft_doc::{CharStyle, TextKind}; +use vectorcraft_geom::PathData; + +fn devanagari_test_face(test: &str) -> Option> { + let db = FontDb::global(); + // The face must have OpenType (GSUB) Devanagari lookups. An AAT face such as macOS's Devanagari MT + // shapes with `morx`, and हिन्दी comes out as three clusters because the conjunct न्दी is split in two. + let opentype = |face: &FontFace| { + use skrifa::raw::TableProvider; + face.skrifa() + .and_then(|f| f.gsub().ok()) + .and_then(|gsub| gsub.script_list().ok()) + .is_some_and(|list| list.script_records().iter().any(|r| matches!(&r.script_tag().to_be_bytes(), b"dev2" | b"deva"))) + }; + let face = ["Kohinoor Devanagari", "ITF Devanagari", "Devanagari Sangam MN", "Noto Sans Devanagari", "Nirmala UI", "Mangal"] + .into_iter() + .filter_map(|name| db.face(name, "Regular").filter(|face| face.family == name)) + .find(|face| { + ['क', 'ष', 'र', 'ह', 'न', 'द', '\u{094D}', '\u{093F}', '\u{0940}', '\u{093C}'].iter().all(|&c| face.covers(c)) && opentype(face) + }); + if let Some(face) = &face { + let path = face.path().map_or_else(|| "bundled font".to_string(), |p| p.display().to_string()); + eprintln!("ASSERTIONS RUN: {test}; Devanagari font {} {} ({path})", face.family, face.style); + } else { + eprintln!("SKIPPED: {test}; no installed candidate font shapes Devanagari through GSUB"); + } + face +} + +fn devanagari_layout(face: &FontFace, text: &str) -> TextLayout { + let st = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, ..CharStyle::default() }; + layout(FontDb::global(), &TextObject::point(Point::ZERO, text, st)) +} + +fn source_clusters(layout: &TextLayout) -> Vec<(usize, usize)> { + let mut clusters: Vec<_> = layout.glyphs.iter().map(|glyph| (glyph.byte, glyph.len)).collect(); + clusters.dedup(); + clusters +} + +/// The left and right edges of the glyphs of the cluster that starts at `byte`. +fn cluster_extent(layout: &TextLayout, byte: usize) -> (f64, f64) { + let glyphs = layout.glyphs.iter().filter(|g| g.byte == byte); + glyphs.fold((f64::INFINITY, f64::NEG_INFINITY), |(left, right), g| (left.min(g.origin.x), right.max(g.origin.x + g.advance))) +} + +#[test] +fn ka_virama_ssa_forms_one_conjunct_glyph() { + let Some(face) = devanagari_test_face("ka_virama_ssa_forms_one_conjunct_glyph") else { return }; + // `akhn` substitutes Ka + halant + Ssa with the KaSsa ligature. + let l = devanagari_layout(&face, "क्ष"); + assert_eq!(l.glyphs.len(), 1, "{:?}", l.glyphs); + let g = &l.glyphs[0]; + assert_eq!((g.byte, g.len), (0, "क्ष".len())); + assert!(!['क', 'ष', '\u{094D}'].iter().any(|&c| face.glyph_for(c) == g.gid), "conjunct drawn with a nominal glyph: {}", g.gid); +} + +#[test] +fn reph_is_drawn_after_its_base_consonant() { + let Some(face) = devanagari_test_face("reph_is_drawn_after_its_base_consonant") else { return }; + // Ra + halant before the base consonant becomes the reph (`rphf`), reordered after the base. + let s = "र्क"; + let l = devanagari_layout(&face, s); + let gids: Vec = l.glyphs.iter().map(|g| g.gid).collect(); + assert_eq!(gids.len(), 2, "{:?}", l.glyphs); + assert_eq!(gids[0], face.glyph_for('क'), "the base consonant comes first: {gids:?}"); + assert!(![face.glyph_for('र'), face.glyph_for('\u{094D}'), face.glyph_for('क')].contains(&gids[1]), "no reph glyph: {gids:?}"); + assert!(l.glyphs[1].origin.x >= l.glyphs[0].origin.x); + assert_eq!(source_clusters(&l), vec![(0, s.len())]); +} + +#[test] +fn pre_base_matra_is_drawn_before_its_consonant() { + let Some(face) = devanagari_test_face("pre_base_matra_is_drawn_before_its_consonant") else { return }; + let s = "कि"; + let l = devanagari_layout(&face, s); + assert_eq!(l.glyphs.len(), 2, "{:?}", l.glyphs); + let (matra, ka) = (&l.glyphs[0], &l.glyphs[1]); + assert_eq!(ka.gid, face.glyph_for('क')); + assert_ne!(matra.gid, ka.gid); + assert!(matra.origin.x < ka.origin.x, "the i matra is drawn to the left: {:?}", l.glyphs); + assert_eq!(source_clusters(&l), vec![(0, s.len())]); + // The caret stops at the syllable's ends: before the matra, and after the consonant. + let (left, right) = cluster_extent(&l, 0); + assert!((caret_position(&l, 0).0.x - left).abs() < 1e-9); + assert!((caret_position(&l, s.len()).0.x - right).abs() < 1e-9); +} + +#[test] +fn syllables_map_to_their_source_bytes_and_caret_stops() { + let Some(face) = devanagari_test_face("syllables_map_to_their_source_bytes_and_caret_stops") else { return }; + // हि + न्दी: the second syllable's matra stays with the conjunct it follows. + let s = "हिन्दी"; + let l = devanagari_layout(&face, s); + let second = "हि".len(); + assert_eq!(source_clusters(&l), vec![(0, second), (second, s.len() - second)]); + // The first syllable's i matra is drawn before its consonant. + assert_eq!(l.glyphs[1].gid, face.glyph_for('ह'), "{:?}", l.glyphs); + assert!(l.glyphs[0].origin.x < l.glyphs[1].origin.x, "{:?}", l.glyphs); + // Left and Right arrows pass over the same syllables the shaper made. + assert_eq!(edit::next_cluster(s, 0), second); + assert_eq!(edit::next_cluster(s, second), s.len()); + assert_eq!(edit::prev_cluster(s, s.len()), second); + assert_eq!(caret_horizontal(&l, 0, true), second); + assert_eq!(caret_horizontal(&l, second, true), s.len()); + assert_eq!(caret_horizontal(&l, s.len(), false), second); + assert!((caret_position(&l, second).0.x - cluster_extent(&l, 0).1).abs() < 1e-9); +} + +#[test] +fn nukta_forms_match_the_precomposed_letters() { + let Some(face) = devanagari_test_face("nukta_forms_match_the_precomposed_letters") else { return }; + // U+0958 is canonically equivalent to Ka + nukta and is drawn the same way. + let composed = devanagari_layout(&face, "\u{0958}"); + let decomposed = devanagari_layout(&face, "\u{0915}\u{093C}"); + let shape = |l: &TextLayout| l.glyphs.iter().map(|g| (g.gid, g.advance, g.origin)).collect::>(); + assert_eq!(shape(&composed), shape(&decomposed)); + assert_eq!(source_clusters(&decomposed), vec![(0, "\u{0915}\u{093C}".len())]); + // The nukta joins its consonant: one glyph takes up room on the line. + assert_eq!(decomposed.glyphs.iter().filter(|g| g.advance > 0.0).count(), 1, "{:?}", decomposed.glyphs); +} + +#[test] +fn a_click_in_a_syllable_goes_to_its_nearer_end() { + let Some(face) = devanagari_test_face("a_click_in_a_syllable_goes_to_its_nearer_end") else { return }; + let s = "कि"; + let l = devanagari_layout(&face, s); + let (left, right) = cluster_extent(&l, 0); + // This point lies just past the matra, still in the left half of the syllable. + let matra_end = l.glyphs[0].origin.x + l.glyphs[0].advance; + let near_left = matra_end + 0.1; + assert!(near_left < (left + right) * 0.5, "the matra takes most of the syllable: {:?}", l.glyphs); + let y = l.lines[0].baseline; + assert_eq!(hit_byte(&l, Point::new(near_left, y)), 0); + assert_eq!(hit_byte(&l, Point::new(right - 0.1, y)), s.len()); + + // Clicks at the same places on a path go to the same ends. + let mut line = BezPath::new(); + line.move_to((0.0, 50.0)); + line.line_to((500.0, 50.0)); + let st = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, ..CharStyle::default() }; + let mut t = TextObject::point(Point::ZERO, s, st); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let p = layout(FontDb::global(), &t); + assert!(p.on_path && p.glyphs.len() == 2, "{:?}", p.glyphs); + let x0 = p.glyphs[0].origin.x; + let matra_end = x0 + p.glyphs[0].advance; + assert_eq!(hit_byte(&p, Point::new(matra_end + 0.1, 50.0)), 0); + assert_eq!(hit_byte(&p, Point::new(p.glyphs[1].origin.x + p.glyphs[1].advance - 0.1, 50.0)), s.len()); +} diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 7030beacf..d44b53f02 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -26,7 +26,7 @@ Chinese by `imachus`). None is recorded as reviewed by a native speaker; the cat | English | `en` | source, 100% | all | Latin | — | full | — | | Simplified Chinese (Mandarin) | `zh-hans` | 3,647 rows; menus, panels, dialogs complete (tests); 85% overall | messages in English (1 of 604) | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | partial | 2–3 | | Spanish | `es` | 4,009 / 4,020 (99.7%) | all (604 messages) | Latin | no | full | — (review) | -| Hindi | `hi` | 0 | none | Devanagari: the document's text shapes (HarfRust), untested for Devanagari, but the egui interface does no complex shaping | no | none | 20–30 | +| Hindi | `hi` | 0 | none | Devanagari: the document's text shapes (HarfRust), with tests of conjuncts, reph, pre-base matras, nukta forms and caret stops that run when an OpenType Devanagari font is installed, but the egui interface does no complex shaping | no | none | 20–30 | | Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 | | French | `fr` | 4,020 / 4,020 (100%) | all | Latin | no | full | — (review) | | Portuguese | `pt-br` | 4,707 catalog entries: menus, `tl!` literals and messages complete (tests); one|other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 722302b45..1806283db 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -557,6 +557,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Simplify dialog also has Corner Point Angle Threshold, Convert to Straight Lines and Show Original Path. The threshold is a slider from 0° to 180° and opens at 150°: an anchor whose corner is sharper than the threshold stays a corner, so moving the slider right keeps more corners, and the dialog sends `cornerAngle` as 180° less the threshold. While Preview is on, Show Original Path draws the paths as they were before the preview in a wide, faint band of their layer's color under the selection's outlines. - **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, 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`). 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. Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). 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. Variation axis sliders are not done. 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). 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 to the Selection tool (#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). - **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, 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`). 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. A double-click on area type's bottom middle handle turns on Auto Size (#1940). 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. Variation axis sliders are not done. 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). 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 to the Selection tool (#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). +- **Devanagari shaping:** with an OpenType Devanagari font, Ka, virama and Ssa (क्ष) form one conjunct glyph, Ra and virama before a consonant (र्क) become a reph drawn after it, the i matra (कि) is drawn before its consonant, and Ka with a nukta is drawn like the precomposed क़ (U+0958), as [Microsoft's Devanagari shaping specification](https://learn.microsoft.com/en-us/typography/script-development/devanagari) describes. Each syllable is one cluster of its source bytes, and the arrow keys stop at its ends. A click inside a syllable drawn with several glyphs puts the caret at the syllable's nearer end, in point, area and path type. Tests: `crates/text/src/tests_devanagari.rs`; they print SKIPPED when no such font is installed. - **Vertical punctuation:** fullwidth `!` (U+FF01) and `?` (U+FF1F) use their nominal glyph upright when no vertical alternate is present, following the Tu fallback in [Unicode UAX #50 §3.1](https://www.unicode.org/reports/tr50/#vo). Code-built test fonts cover point and area type with and without `vert` alternates, retain the rotated fallback for a bracket and long vowel mark, and leave horizontal type unchanged. Tests: `crates/text/src/tests_vertical.rs`. - Free Distort's dialog (Effect › Distort & Transform › Free Distort…) shows a box in the proportions of the first selected object's bounds with a handle on each corner. Dragging a handle moves that corner. A handle focused by a click or Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset puts the four corners back on the box. The preview and OK run `effect.apply`, or `effect.setParams` when the dialog edits an applied effect, and the Appearance panel's inline editor opens the dialog with Edit…. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 285 (PDF page 291). - **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, 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`). 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. Variation axis sliders are not done. 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). 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 to the Selection tool (#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). From bf785840e51253070f3df754375b84547c0b0d29 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:52:03 +0700 Subject: [PATCH 082/208] Docs: file-format-parity keeps one PNG/TIFF row with both notes --- docs/file-format-parity.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 516e64f71..ec4aef929 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -101,8 +101,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | | PSD | open, place, export | yes (merged image, every mode and depth) | yes (layered, max editability) | placing as layers (Photoshop Import Options) missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | | PSB | open | yes (merged image) | no | | -| PNG / JPEG / GIF / BMP / TIFF / Targa | open, export | yes (Targa: export only) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa import missing; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), and so do files that store each channel as its own plane (PlanarConfiguration 2), CMYK ones with their ink amounts; CCITT Group 3 doesn't; the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584) | -| PNG / JPEG / GIF / BMP / TIFF / Targa | open, export | yes (Targa: export only) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa import missing; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), CCITT Group 3 and planar files with extra channels don't; the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | +| PNG / JPEG / GIF / BMP / TIFF / Targa | open, export | yes (Targa: export only) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa import missing; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), and so do files that store each channel as its own plane (PlanarConfiguration 2), CMYK ones with their ink amounts; CCITT Group 3 doesn't; the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | | WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | | HEIC / HEIF | open, place | no | no | | | JPEG 2000 | open, place | no | no | | From c5d2297ef1de79e64e9561af676f87fc5217c86c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 17:28:48 -0700 Subject: [PATCH 083/208] Docs: type-parity and the roadmap list the Hyphenation options as done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/type-parity.md and the Set type row of docs/roadmap.md still listed the Hyphenation… options as missing, although the Hyphenation dialog (#2310) sets them for each paragraph. The type-parity row is now partial and names Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone and Hyphenate Capitalized Words. Justification… stays missing. The Set type row of roadmap.md lists only the justification options as missing. Its score is unchanged. docs/target-app-parity.md and docs/gaps.md are left as they are. --- docs/roadmap.md | 2 +- docs/type-parity.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index dcaf625a9..0d9904586 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -21,7 +21,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | 2026-10-12 | minor | Desktop PDF spooler shared through craft-print; exact physical sizes, composed copies and Windows 7 fallback retained. Scores and remaining gaps unchanged. | | Draw and edit paths precisely: Pen, Curvature, Direct Selection on anchors and handles, smart guides and snapping, Pathfinder | yes | `crates/tools/src/pen.rs`, `direct.rs`, `guides.rs` and their tests; 10 exact Pathfinder booleans; [ui-parity.md](ui-parity.md). Shape Builder misses regions in some files (#937), Pathfinder covers the same results | 0 | | Paint and style: fills and strokes, global and spot swatches, gradients, Appearance, transparency, graphic styles | yes | M3 done (92 tasks); area scores 92–97% in [target-app-parity.md](target-app-parity.md#feature-areas) | 0 | -| Set type: point and area type, Character and Paragraph styles, threading, outlines | yes | type core 80%; styles, threading, Create Outlines and font matching tested ([type-parity.md](type-parity.md)); hyphenation and justification options missing (not blocking) | 0 | +| Set type: point and area type, Character and Paragraph styles, threading, outlines | yes | type core 80%; styles, threading, Create Outlines and font matching tested ([type-parity.md](type-parity.md)); justification options missing (not blocking) | 0 | | Organise layers and artboards and export assets (Export for Screens, SVG, PNG, PDF per artboard) | yes | Layers panel rework, Artboards panel and tool, Export for Screens and Asset Export done; Art Optimized supersample closes ghost borders (#983 / #1037) | 0 | | Print production: CMYK and spot colour, bleed and marks, PDF/X out, separations preview | yes | PDF/X-1a/3/4, presets, marks and bleed, ICC output intent, Separations Preview, Print with PostScript output | 0 | | Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951) and lose guides (#779); Save As `.ai` is PDF-compatible, which Illustrator opens without its own editing data (#868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | diff --git a/docs/type-parity.md b/docs/type-parity.md index beb684425..3a00a019a 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -33,7 +33,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Variable fonts | partial | named instances as styles; axis sliders missing | | Colour fonts (OpenType SVG / COLR) | partial | | | Every-line and Single-line composers | done | tuned by eye, never compared side by side | -| Hyphenation… and Justification… options | missing | | +| Hyphenation… and Justification… options | partial | Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310); Justification… missing (menu stub) | | Optical Margin Alignment | missing | menu stub | | Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877) | | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | From 1e87e85ab7ae3f70e68f00db2aeb579f93258c7d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 15:46:37 -0700 Subject: [PATCH 084/208] i18n: Simplified Chinese translates status-bar and error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Simplified Chinese catalog had 20 `@msg` rows, against 714 to 718 in each catalog listed in COMPLETE_MESSAGES, so with the interface in Simplified Chinese most status-bar and error messages showed in English. - zh-hans.tsv gains the 694 missing `@msg` rows in one block with a header comment on its conventions: no final 。 unless the English ends with a period, a space between Chinese and adjacent Latin text, numbers or placeholders, and 、 between list items. The rows use the catalog's existing terms (画板, 图稿, 编组, 描边, 色板, 实时上色, 图像描摹, 增效工具 ...). - zh-hans joins COMPLETE_MESSAGES, so complete_languages_translate_every_message and the narrower message tests fail when a zh-hans message row is missing, and a new message needs a zh-hans row. - The registry comment, docs/development.md (which also gains Greek, already in COMPLETE_MESSAGES), the zh-hans row of docs/localization-parity.md, and the interface-languages line of docs/target-app-parity.md (which also gains Czech, Greek, Hungarian, Swedish and Finnish) list Simplified Chinese among the languages that translate messages. --- crates/ui-egui/src/i18n/mod.rs | 4 +- crates/ui-egui/src/i18n/tests.rs | 2 +- crates/ui-egui/src/i18n/zh-hans.tsv | 700 ++++++++++++++++++++++++++++ docs/development.md | 2 +- docs/localization-parity.md | 2 +- docs/target-app-parity.md | 2 +- 6 files changed, 706 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 9698fb82a..d5cc3a5a8 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -223,8 +223,8 @@ pub static LANGUAGES: [LangInfo; 23] = [ complete_menus: true, catalog: OnceLock::new(), }, - // Simplified Chinese in the vocabulary used in mainland China; `zh-CN`, `zh-SG`, `zh-Hans-*` and a - // bare `zh` resolve here (see `candidates`). + // Simplified Chinese in the vocabulary used in mainland China: the whole interface and the status + // and error messages; `zh-CN`, `zh-SG`, `zh-Hans-*` and a bare `zh` resolve here (see `candidates`). LangInfo { code: "zh-hans", name: "简体中文", diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 3ab33db42..a17831fbe 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1290,7 +1290,7 @@ fn select_key_param_error_is_translated() { } /// Languages whose catalogs cover every status and error message. -const COMPLETE_MESSAGES: &[&str] = &["cs", "de", "el", "es", "fr", "hu", "it", "ja", "ko", "pl", "pt-br", "ru", "sv", "uk", "vi", "fi"]; +const COMPLETE_MESSAGES: &[&str] = &["cs", "de", "el", "es", "fr", "hu", "it", "ja", "ko", "pl", "pt-br", "ru", "sv", "uk", "vi", "zh-hans", "fi"]; /// Crates whose error and status messages reach the status bar. const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad", "trace"]; diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c1e8299d3..cdb7f3f12 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3381,6 +3381,706 @@ imageTrace Outline 轮廓 Set: 集: Shortcut 快捷键 Use Default 使用默认值 +# Status and error messages (`@msg`), translated where the status bar shows them; the message itself +# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order +# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter +# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. +# Simplified Chinese: no final 。 unless the English ends with a period; a space between Chinese text and +# an adjacent Latin word, number or {placeholder}; 、 between the items of a list. +@msg `{key}` must be an array of these names: {_1} `{key}` 必须是由以下名称组成的数组:{_1} +@msg Reset {_1} 重置 {_1} +@msg Saving 正在保存 +@msg Saving {_1} 正在保存 {_1} +@msg Sent to {_1} 已发送到 {_1} +@msg `to` must be "corner" or "smooth" `to` 必须是 "corner" 或 "smooth" +@msg a layer couldn't be read 无法读取某个图层 +@msg it has more than one artboard 它有多个画板 +@msg its editing data has no layers 其编辑数据中没有图层 +@msg its editing data is damaged 其编辑数据已损坏 +@msg its type is to come in as outlines, which only its page has 其文字需要以轮廓导入,而只有其页面中包含这些轮廓 +@msg {_1} files {_1} 文件 +@msg A browser with WebGPU or WebGL2 is required. 需要支持 WebGPU 或 WebGL2 的浏览器。 +@msg Loading VectorCraft… 正在加载 VectorCraft… +@msg Reload the page to go on (changes not saved yet may be lost). 请重新加载页面以继续 (尚未保存的更改可能会丢失)。 +@msg The graphics were lost and couldn't be restarted. 图形已丢失,且无法重新启动。 +@msg The graphics were lost and have been restarted 图形已丢失,现已重新启动 +@msg VectorCraft failed to start: {e} VectorCraft 启动失败:{e} +@msg Your unsaved changes are kept in this browser: reload the page to get them back. 您未保存的更改保留在此浏览器中:重新加载页面即可找回。 +@msg The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary} 首先尝试的图形处理器无法显示窗口,因此 VectorCraft 已在 {summary} 上重新启动 +@msg The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set VectorCraft 上次启动时在其窗口出现之前于 {name} 上终止,因此本次在 {summary} 上启动。要再次尝试该图形处理器,请更改“首选项 › 性能 › 图形处理器”,或在设置 WGPU_BACKEND 后启动 +@msg The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set VectorCraft 上次启动时在列出图形处理器期间终止,因此本次仅使用 OpenGL 在 {summary} 上启动。要再次尝试其他图形后端,请更改“首选项 › 性能 › 图形处理器”,或在设置 WGPU_BACKEND 后启动 +@msg not a DXF drawing 不是 DXF 图纸 +@msg scale must be a number from {_1} to {_2}, not {s} 比例必须是 {_1} 到 {_2} 之间的数字,而不是 {s} +@msg the artboard to fit must be a positive size up to {_1} pt 要适合的画板尺寸必须为正数,且不超过 {_1} pt +@msg the drawing has no layout `{_1}` (it has {_2}) 图纸中没有布局 `{_1}` (它包含 {_2}) +@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that 这是 DWG 图纸,无法读取:请在 CAD 应用中将其保存为 ASCII DXF,然后打开该文件 +@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard 按此比例,图纸为 {w} × {h} pt,超出画布的容量:请选择较小的比例,或使其适合画板 +@msg the scale is not a usable number 比例不是可用的数字 +@msg the drawing is too large to open 图纸过大,无法打开 +@msg scale must be a number from {_1} to {_2}, not {_3} 比例必须是 {_1} 到 {_2} 之间的数字,而不是 {_3} +@msg image '{key}' has no pixels and was left out 图像 '{key}' 没有像素,已略去 +@msg Embedded {n} image(s) 已嵌入 {n} 张图像 +@msg object {0} cannot have children 对象 {0} 不能包含子对象 +@msg range `{s}` is empty 范围 `{s}` 为空 +@msg range `{s}`: `{part}` names no number 范围 `{s}`:`{part}` 未指定数字 +@msg range `{s}`: `{part}` runs backwards 范围 `{s}`:`{part}` 的顺序颠倒 +@msg range `{s}`: `{t}` is not a number from 1 to {count} 范围 `{s}`:`{t}` 不是 1 到 {count} 之间的数字 +@msg not a graphic style library (format `{_1}`) 不是图形样式库 (格式为 `{_1}`) +@msg Show All Hidden Attributes: nothing is hidden 显示所有隐藏属性:没有隐藏任何内容 +@msg Expand: no brush strokes selected 扩展:未选择画笔描边 +@msg no brush named `{name}` 没有名为 `{name}` 的画笔 +@msg no brush selected 未选择画笔 +@msg no symbol instances selected 未选择符号实例 +@msg select a symbol instance 请选择一个符号实例 +@msg the art has no bounds 图稿没有边界 +@msg Image Trace: the traced image is missing 图像描摹:缺少所描摹的图像 +@msg Live Paint Merge: select a Live Paint group and the paths to add 实时上色合并:请选择一个实时上色组以及要添加的路径 +@msg Live Paint: select paths or compound paths 实时上色:请选择路径或复合路径 +@msg Live Paint: these paths are too degenerate to paint 实时上色:这些路径过于退化,无法上色 +@msg Shape Builder: no region under the pointer 形状生成器:指针下方没有区域 +@msg Shape Builder: select paths or compound paths 形状生成器:请选择路径或复合路径 +@msg image data `{_1}` is missing 缺少图像数据 `{_1}` +@msg no Live Paint edge near that point 该点附近没有实时上色边缘 +@msg no Live Paint face at that point 该点处没有实时上色表面 +@msg select a Live Paint group 请选择一个实时上色组 +@msg select an Image Trace object 请选择一个图像描摹对象 +@msg select objects, not layers 请选择对象,而不是图层 +@msg Blend colors: select at least three objects with filled colours 混合颜色:请至少选择三个填有颜色的对象 +@msg no saved themes 没有已保存的主题 +@msg `{keep}` is not a side of this axis `{keep}` 不是此轴的一侧 +@msg unknown side `{other}` (left|right|top|bottom) 未知的侧边 `{other}` (left|right|top|bottom) +@msg an object in perspective would leave the edited grid 透视中的对象将离开所编辑的网格 +@msg nothing to warp 没有可变形的内容 +@msg select an object in perspective 请选择透视中的对象 +@msg select the artwork to warp 请选择要变形的图稿 +@msg select the objects to transform in perspective 请选择要在透视中变换的对象 +@msg select type in perspective 请选择透视中的文字 +@msg the object has no geometry 该对象没有几何形状 +@msg the object has no stroke 该对象没有描边 +@msg the object is beyond the plane's horizon 该对象位于平面的地平线之外 +@msg the object isn't in perspective 该对象不在透视中 +@msg the object isn't on a perspective plane 该对象不在透视平面上 +@msg the object would cross the horizon of the perspective plane 该对象将越过透视平面的地平线 +@msg the objects would leave the grid 这些对象将离开网格 +@msg the pointer is beyond the plane's horizon 指针位于平面的地平线之外 +@msg the selection isn't in perspective 所选对象不在透视中 +@msg the stroke has no weight 描边没有粗细 +@msg {command} didn't create an object {command} 未创建对象 +@msg Make Guides: select paths 创建参考线:请选择路径 +@msg Release Guides: no guides 释放参考线:没有参考线 +@msg guides are locked 参考线已锁定 +@msg no saved selection recalled 未调出任何已保存的选区 +@msg no saved selections 没有已保存的选区 +@msg no such artboard 没有此画板 +@msg cannot split an open path at its end point 无法在开放路径的端点处拆分该路径 +@msg closing needs three anchors 闭合需要三个锚点 +@msg empty brush stroke 画笔描边为空 +@msg no such anchor 没有此锚点 +@msg no such segment 没有此线段 +@msg no such subpath 没有此子路径 +@msg object {id} is not a path 对象 {id} 不是路径 +@msg object {id} is not a selected top-level object 对象 {id} 不是选中的顶层对象 +@msg nothing to redo 没有可重做的操作 +@msg nothing to undo 没有可撤销的操作 +@msg Expand Appearance: the selection has a basic appearance 扩展外观:所选对象只有基本外观 +@msg select (or target) objects whose appearance isn't basic 请选择 (或定位) 外观不是基本外观的对象 +@msg {C}: no effect at index {from} {C}:索引 {from} 处没有效果 +@msg Expand: no gradient fills 扩展:没有渐变填色 +@msg Expand: nothing to expand 扩展:没有可扩展的内容 +@msg batch step {_1} (`{id}`) failed: {e} 批处理步骤 {_1} (`{id}`) 失败:{e} +@msg BMP files are RGB or grayscale: colorModel rgb or gray BMP 文件为 RGB 或灰度:colorModel rgb 或 gray +@msg Targa files are RGB: colorModel rgb Targa 文件为 RGB:colorModel rgb +@msg antiAlias `{id}`: none, art or type antiAlias `{id}`:none、art 或 type +@msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}:transparent、white、black 或颜色,例如 "#ff8800" +@msg no artboard {i} (0-based; the document has {count}) 没有画板 {i} (从 0 开始计数;文档有 {count} 个) +@msg no artboards named 未指定画板 +@msg the document has no artboard 文档没有画板 +@msg this format holds one artboard: export several as PDF or with document.exportForScreens 此格式只能包含一个画板:要导出多个画板,请导出为 PDF 或使用 document.exportForScreens +@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) 此文件保存时未启用 PDF 兼容性,且无法读取其编辑数据 ({why}) +@msg the export wrote no file 导出未写入任何文件 +@msg {_1} files can be opened but not written (see document.formats) {_1} 文件可以打开,但不能写入 (请参阅 document.formats) +@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}:此处没有文件系统 (要打开文件,请传入 dataBase64;省略路径可获取 dataBase64) +@msg Save writes {_1}, not {_2}: use document.export for other formats 保存会写入 {_1},而不是 {_2}:其他格式请使用 document.export +@msg no changes since the last save 自上次保存以来没有更改 +@msg unknown format `{f}` (Save writes {_1}) 未知格式 `{f}` (保存会写入 {_1}) +@msg `svg` must be an object of SVG options (see document.formats) `svg` 必须是由 SVG 选项组成的对象 (请参阅 document.formats) +@msg no end record 没有结束记录 +@msg not a stored central record 中央目录记录不是未压缩 (stored) 方式 +@msg truncated 已截断 +@msg {name}: CRC mismatch {name}:CRC 不匹配 +@msg {name}: no local header {name}:没有本地文件头 +@msg Flatten Transparency: select objects to flatten 拼合透明度:请选择要拼合的对象 +@msg Flatten Transparency: {e} 拼合透明度:{e} +@msg balance must be between 0 and 100 平衡必须介于 0 到 100 之间 +@msg resolutions must be between 1 and 2400 ppi 分辨率必须介于 1 到 2400 ppi 之间 +@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) 未知预设 `{name}` (high、medium、low 或已保存的预设:请参阅 flattener.presets.list) +@msg `at` must be [x, y] `at` 必须是 [x, y] +@msg `color` must be a colour `color` 必须是颜色 +@msg `freeform.points` must be an array `freeform.points` 必须是数组 +@msg `freeform.lines` must be an array `freeform.lines` 必须是数组 +@msg `{key}` must be a number `{key}` 必须是数字 +@msg a freeform gradient keeps at least one point 任意形状渐变至少保留一个点 +@msg freeform point {i} needs `at` [x, y] and a valid `color` 任意形状点 {i} 需要 `at` [x, y] 和有效的 `color` +@msg freeform line {i} must be an array of point indices 任意形状线 {i} 必须是由点索引组成的数组 +@msg no point {i} (the gradient has {_1}) 没有点 {i} (该渐变有 {_1} 个) +@msg no segment {segment} on line {line} 线 {line} 上没有线段 {segment} +@msg unknown freeform mode `{m}` (points, lines) 未知的任意形状模式 `{m}` (points、lines) +@msg `aspect` must be a number (%) `aspect` 必须是数字 (%) +@msg `gradient` must be an object `gradient` 必须是对象 +@msg `mode` applies to freeform gradients `mode` 仅适用于任意形状渐变 +@msg `stops` must be an array `stops` 必须是数组 +@msg `{k}` must be [x, y] `{k}` 必须是 [x, y] +@msg a gradient needs at least two stops 渐变至少需要两个色标 +@msg give both `start` and `end` [x, y] (or neither) 请同时给出 `start` 和 `end` [x, y] (或都不给出) +@msg stop {i} needs `offset` 色标 {i} 需要 `offset` +@msg stop {i} needs a valid `color` 色标 {i} 需要有效的 `color` +@msg stop {i}: `{k}` must be a number 色标 {i}:`{k}` 必须是数字 +@msg unknown gradient kind `{k}` (linear, radial, freeform) 未知的渐变类型 `{k}` (linear、radial、freeform) +@msg the art could not be sampled 无法对图稿取样 +@msg the art is too light for any dots 图稿太浅,无法生成任何网点 +@msg the document has no symbols 文档中没有符号 +@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` 不是颜色 (#rrggbb 或预设名称) +@msg a colour index is 0 to {_1}, not {n} 颜色索引应为 0 到 {_1},而不是 {n} +@msg a colour is an index, #rrggbb or a preset name, not {v} 颜色应为索引、#rrggbb 或预设名称,而不是 {v} +@msg a document needs at least one layer 文档至少需要一个图层 +@msg no artwork 没有图稿 +@msg no selection bounds 所选对象没有边界 +@msg the layer has no objects to clip by 该图层中没有可用于剪切的对象 +@msg the layer or group is locked 该图层或编组已锁定 +@msg the guide is on a hidden or locked layer 该参考线位于隐藏或锁定的图层上 +@msg the target layer is locked or hidden 目标图层已锁定或隐藏 +@msg the layer to flatten into is hidden 要拼合到的图层已隐藏 +@msg there are no objects to release 没有可释放的对象 +@msg no library `{id}` 没有库 `{id}` +@msg {path}: not a file {path}:不是文件 +@msg {path}: {e} {path}:{e} +@msg can't write .{ext} images 无法写入 .{ext} 图像 +@msg Replace Spine: select a blend and a path 替换混合轴:请选择一个混合对象和一条路径 +@msg nothing to wrap 没有可绕排的内容 +@msg select a blend 请选择一个混合对象 +@msg select a gradient mesh 请选择一个渐变网格 +@msg the object has no opacity mask 该对象没有不透明度蒙版 +@msg Convert to Artboards: selection has no area 转换为画板:所选对象没有面积 +@msg Convert to Shape: no rectangles or ellipses selected 转换为形状:未选择矩形或椭圆 +@msg image would be too large; lower the resolution 图像将过大;请降低分辨率 +@msg clipboard is empty 剪贴板为空 +@msg no anchor points selected 未选择锚点 +@msg select at least two objects 请至少选择两个对象 +@msg no saved presets 没有已保存的预设 +@msg compound paths can only contain paths 复合路径只能包含路径 +@msg no bounds 没有边界 +@msg no current layer 没有当前图层 +@msg no key object 没有关键对象 +@msg no previous transform 没有上一次变换 +@msg nothing to align 没有可对齐的内容 +@msg select a clip group 请选择一个剪切组 +@msg select two or more objects to distribute 请选择两个或更多要分布的对象 +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis 必须是 "vertical"、"horizontal" 或以度为单位的角度,而不是 `{v}` +@msg give horizontal or vertical 请给出 horizontal 或 vertical +@msg selection has no bounds 所选对象没有边界 +@msg the top object must be a path, compound path or text object to use as a clipping mask 最上层对象必须是路径、复合路径或文本对象才能用作剪切蒙版 +@msg no target object has an opacity mask: select one or give `id` 没有目标对象带有不透明度蒙版:请选择一个,或给出 `id` +@msg the object already has an opacity mask 该对象已有不透明度蒙版 +@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` 是印刷色:色调仅适用于全局色和专色 +@msg `{name}` isn't a colour swatch `{name}` 不是颜色色板 +@msg a document needs at least one artboard 文档至少需要一个画板 +@msg empty path 路径为空 +@msg join needs open paths 连接需要开放路径 +@msg path is already closed 路径已闭合 +@msg select paths to join 请选择要连接的路径 +@msg direct-select anchor points to cut at (not the end points of open paths) 请直接选择要在其处切割的锚点 (不能是开放路径的端点) +@msg select paths or anchor points 请选择路径或锚点 +@msg Divide Objects Below: select a path 分割下方对象:请选择一条路径 +@msg Divide Objects Below: select exactly one path 分割下方对象:请只选择一条路径 +@msg Offset Path: select paths 偏移路径:请选择路径 +@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) 偏移路径:偏移后所选路径不剩任何部分 (内缩量大于形状) +@msg Outline Stroke: no stroked paths selected 轮廓化描边:未选择带描边的路径 +@msg Split Into Grid: select paths large enough for the grid 分割为网格:请选择足够大、可容纳网格的路径 +@msg empty cutter 切割对象为空 +@msg paths contain invalid coordinates 路径包含无效坐标 +@msg {label} produced an empty result {label} 生成了空结果 +@msg {label}: lost track of a shape's style {label}:丢失了某个形状的样式 +@msg {label}: select paths {label}:请选择路径 +@msg {label}: select paths, compound paths, groups or text {label}:请选择路径、复合路径、编组或文字 +@msg already editing a pattern 已在编辑图案 +@msg no pattern `{name}` 没有图案 `{name}` +@msg not in pattern editing mode 不在图案编辑模式中 +@msg pattern missing 缺少图案 +@msg select a repeat 请选择一个重复对象 +@msg select an Intertwine group 请选择一个交织组 +@msg select the art to repeat 请选择要重复的图稿 +@msg the document has no patterns 文档中没有图案 +@msg no artboard 没有画板 +@msg installing from a path is not available on the web; pass the module as `dataBase64` 在网页版中无法从路径安装;请以 `dataBase64` 传入模块 +@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` 必须是 `auto` 或语言代码,例如 `en` 或 `zh-hant` +@msg `{key}` must be a #rrggbb colour `{key}` 必须是 #rrggbb 颜色 +@msg `{key}` must be a string `{key}` 必须是字符串 +@msg `{key}` must be a whole number `{key}` 必须是整数 +@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 unknown preference `{key}` 未知首选项 `{key}` +@msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) 数据恢复没有可保存副本的位置:请设置“首选项 → 文件处理 → 数据恢复 → 文件夹” (recoveryFolder) +@msg `{area}` is no recovery area name `{area}` 不是恢复区域名称 +@msg `{name}` is no recovery entry name `{name}` 不是恢复条目名称 +@msg no clock to keep a heartbeat by 没有可用于维持心跳的时钟 +@msg no free recovery area 没有空闲的恢复区域 +@msg no native format 没有原生格式 +@msg nothing encoded 没有编码任何内容 +@msg recovery copy `{file}`: {e} 恢复副本 `{file}`:{e} +@msg {name}: no such recovery entry {name}:没有此恢复条目 +@msg Combine Slices: the slices have no bounds 组合切片:切片没有边界 +@msg Create from Guides: the document has no artboard 从参考线创建:文档没有画板 +@msg Create from Guides: the guides make more than {MAX_PIECES} slices 从参考线创建:参考线将生成超过 {MAX_PIECES} 个切片 +@msg Create from Selection: the selection has no area 从选区创建:所选对象没有面积 +@msg Release: no layer to put the rectangle in 释放:没有可放置矩形的图层 +@msg a slice size must be a positive number of points 切片尺寸必须是正数 (以点为单位) +@msg divide into 1 to {MAX_PIECES} slices 请划分为 1 到 {MAX_PIECES} 个切片 +@msg no slice selected 未选择切片 +@msg the document has no slices 文档中没有切片 +@msg no saved profiles 没有已保存的配置文件 +@msg select a stroke with a variable width 请选择一个宽度可变的描边 +@msg the selected stroke's profile is already listed as {_1} 所选描边的配置文件已列为 {_1} +@msg a graphic style named `{to}` already exists 名为 `{to}` 的图形样式已存在 +@msg no graphic style `{name}` 没有图形样式 `{name}` +@msg node {_1} is not text 节点 {_1} 不是文字 +@msg Text Wrap: select a wrap object 文本绕排:请选择一个绕排对象 +@msg layers can't be wrap objects 图层不能作为绕排对象 +@msg no threaded text selected 未选择串接文本 +@msg Create Outlines: select text objects 创建轮廓:请选择文本对象 +@msg paste unavailable 无法粘贴 +@msg Save for Web: the image is empty 存储为 Web 所用格式:图像为空 +@msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}:`{h}` 不是颜色,例如 "#ff8800" +@msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}:最多 {MAX_TABLE} 种颜色 +@msg no artboard {i} (0-based; the document has {_1}) 没有画板 {i} (从 0 开始计数;文档有 {_1} 个) +@msg no selected slice in the image: select slices, or save all of them 图像中没有选中的切片:请选择切片,或全部保存 +@msg nothing to save: the document has no visible art 没有可保存的内容:文档中没有可见的图稿 +@msg percent must be above 0 and at most 6400, not {p} 百分比必须大于 0 且不超过 6400,而不是 {p} +@msg unknown preset `{name}` (see webExport.presets.list) 未知预设 `{name}` (请参阅 webExport.presets.list) +@msg width and height are at least 1 pixel 宽度和高度至少为 1 像素 +@msg `sampleSize` must be 1, 3 or 5 `sampleSize` 必须是 1、3 或 5 +@msg `{path}` must be a bool or an object of bools `{path}` 必须是布尔值或由布尔值组成的对象 +@msg cannot distort a zero-size bounding box 无法扭曲尺寸为零的定界框 +@msg nothing to distort 没有可扭曲的内容 +@msg unknown option `{path}.{k}` 未知选项 `{path}.{k}` +@msg command `{0}` is not available right now: {1} 命令 `{0}` 当前不可用:{1} +@msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) `{cmd}` 中出现内部错误:{msg} (文档保持原样;请报告此错误) +@msg invalid parameters for `{cmd}`: {msg} `{cmd}` 的参数无效:{msg} +@msg no active document 没有活动文档 +@msg no interaction in progress 没有正在进行的交互 +@msg no such object {0} 没有对象 {0} +@msg result would exceed the canvas (coordinates out of range) 结果将超出画布 (坐标超出范围) +@msg objects can't be nested more than 32 deep 对象的嵌套不能超过 32 层 +@msg the SVG's elements are nested more than {0} deep SVG 的元素嵌套超过 {0} 层 +@msg unknown command `{0}` 未知命令 `{0}` +@msg `{what}` closes nothing `{what}` 没有可结束的内容 +@msg `{what}` closes something it didn't open `{what}` 结束了并非由它开始的内容 +@msg a path has too many points 某条路径的点过多 +@msg it ends inside a layer 它在图层内部结束 +@msg it has art outside its layers 它有位于图层之外的图稿 +@msg it has more than {MAX_NODES} objects 它包含超过 {MAX_NODES} 个对象 +@msg it has no layers 它没有图层 +@msg it has too many values in a row 它的一行中值过多 +@msg it has {_1}, which VectorCraft doesn't read from it yet 它包含 {_1},VectorCraft 尚不能从中读取 +@msg its objects are nested too deeply 其对象嵌套过深 +@msg its section ending with `%{end}` doesn't end 其以 `%{end}` 结束的部分没有结束 +@msg an image VectorCraft can't read was left out 已略去 VectorCraft 无法读取的图像 +@msg an image whose data is cut short was left out 已略去数据不完整的图像 +@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 文件中未定义渐变“{name}”:已改用纯色代替 +@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 带有多个填色或描边、效果或画笔的对象已按其绘制外观导入 ({_1}):其外观尚不可编辑 +@msg patterns nested too deeply are filled with mid-grey 嵌套过深的图案以中灰色填充 +@msg shadings of an unknown type were left out 已略去未知类型的着色 +@msg shadings whose functions are of an unknown type are filled with their middle colour 函数类型未知的着色以其中间颜色填充 +@msg the file's preview header points outside the file 文件的预览头指向文件之外 +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it 无法读取此 PostScript 文件 ({why}):请在创建它的应用中将其保存为 PDF 或 SVG +@msg this is not a PostScript file 这不是 PostScript 文件 +@msg a PostScript error ({name} in `{op}`) PostScript 错误 ({name},位于 `{op}`) +@msg a PostScript error ({name} in `{op}`), in {procs} PostScript 错误 ({name},位于 `{op}`),在 {procs} 中 +@msg a PostScript error ({name} in `{op}`: {at}) PostScript 错误 ({name},位于 `{op}`:{at}) +@msg a PostScript error ({name} in `{op}`: {at}), in {procs} PostScript 错误 ({name},位于 `{op}`:{at}),在 {procs} 中 +@msg a PostScript error ({name}) PostScript 错误 ({name}) +@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know 它使用了 `{at}`,VectorCraft 的 PostScript 读取器无法识别 +@msg the file was read up to an error ({why}): what follows it was left out 文件已读取到出错处 ({why}):其后的内容已略去 +@msg this file's PostScript couldn't be read ({why}): {what} 无法读取此文件的 PostScript ({why}):{what} +@msg it has no page 它没有页面 +@msg it has too many text objects 它的文本对象过多 +@msg it has too much text 它的文字过多 +@msg its editing data doesn't say where its art is 其编辑数据未说明其图稿的位置 +@msg its editing data doesn't say where its artboard is 其编辑数据未说明其画板的位置 +@msg its editing data is for a different page 其编辑数据属于另一个页面 +@msg its page has text, and its layers have no text objects to put it in 其页面中有文字,但其图层中没有可放置这些文字的文本对象 +@msg the art on them differs from the page's in {_1}% of it 其上的图稿与页面的图稿有 {_1}% 不同 +@msg the art on them differs from the page's in {_1}% of it: they come from its editing data all the same 其上的图稿与页面的图稿有 {_1}% 不同:它们仍从其编辑数据导入 +@msg the file's layers weren't read ({_1}): it comes in as its page, in one layer 未读取文件的图层 ({_1}):文件以其页面导入,位于一个图层中 +@msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part 未从文件的编辑数据中读取图层 ({_1}):图层来自其 PDF 部分 +@msg its Windows metafile preview was opened instead, which may simplify the art 已改为打开其 Windows 图元文件预览,图稿可能会被简化 +@msg its preview was placed instead, at screen resolution 已改为以屏幕分辨率置入其预览 +@msg shadings of more than 4096 patches or triangles were left out 已略去超过 4096 个面片或三角形的着色 +@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know, in {procs} 它在 {procs} 中使用了 `{at}`,VectorCraft 的 PostScript 读取器无法识别 +@msg the export region is not finite 导出区域不是有限值 +@msg a page out of range 页面超出范围 +@msg a page's placement is not finite 页面的位置不是有限值 +@msg nothing to print 没有可打印的内容 +@msg page size {w} × {h} 页面尺寸 {w} × {h} +@msg the preview image is empty 预览图像为空 +@msg the thumbnail image is malformed 缩览图格式错误 +@msg can't encode the document: {0} 无法编码文档:{0} +@msg can't save version {0}: VectorCraft writes versions {MIN_VERSION} to {VERSION} 无法保存版本 {0}:VectorCraft 写入的版本为 {MIN_VERSION} 到 {VERSION} +@msg file version {0} is newer than this VectorCraft supports ({VERSION}) 文件版本 {0} 比此 VectorCraft 支持的版本 ({VERSION}) 更新 +@msg invalid image data for `{0}` `{0}` 的图像数据无效 +@msg not a VectorCraft file: {0} 不是 VectorCraft 文件:{0} +@msg the compressed file unpacks to more than {_1} MB 压缩文件解压后超过 {_1} MB +@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files 版本 {0} 的文件不能压缩:只有版本 {COMPRESSED_SINCE} 及更高版本能打开压缩文件 +@msg a bitmap is empty 某个位图为空 +@msg a bitmap is too large to read 某个位图过大,无法读取 +@msg compressed (RLE) bitmaps can't be read yet 尚不能读取压缩 (RLE) 位图 +@msg the image's pixels don't match its size 图像的像素与其尺寸不符 +@msg {bpp}-bit bitmaps can't be read 无法读取 {bpp} 位位图 +@msg not an EMF or WMF file 不是 EMF 或 WMF 文件 +@msg its layers use {_1}, which aren't read yet 其图层使用了 {_1},尚不能读取 +@msg the EMF header is damaged EMF 文件头已损坏 +@msg damaged 已损坏 +@msg the WMF header is damaged WMF 文件头已损坏 +@msg the export region is empty 导出区域为空 +@msg a stream runs past the end 某个流超出了结尾 +@msg a stream's length is wrong 某个流的长度错误 +@msg a stream's length isn't a number 某个流的长度不是数字 +@msg bad stream length 流长度错误 +@msg PDF writer error: {0} PDF 写入器错误:{0} +@msg artboard {0} does not exist 画板 {0} 不存在 +@msg cannot read PDF: {0} 无法读取 PDF:{0} +@msg invalid PDF setting: {0} PDF 设置无效:{0} +@msg page {0} does not exist (the PDF has {1}) 页面 {0} 不存在 (该 PDF 有 {1} 页) +@msg the PDF has no pages 该 PDF 没有页面 +@msg the PDF is password-protected: give its password 该 PDF 受密码保护:请提供其密码 +@msg the PDF password is wrong PDF 密码错误 +@msg the file holds only a placeholder page, not its art (it was saved without PDF compatibility): save it again with PDF compatibility on, or as PDF or SVG 该文件只包含占位页面,不包含其图稿 (保存时未启用 PDF 兼容性):请在启用 PDF 兼容性后重新保存,或保存为 PDF 或 SVG +@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS 这是 PostScript 文件 (以旧格式或未启用 PDF 兼容性保存的 .ai,或 EPS 文件),不是 PDF:“文件 › 打开”和“文件 › 置入”会将其作为 EPS 读取 +@msg unsupported: {0} 不支持:{0} +@msg a page is missing 缺少某个页面 +@msg a page tree kid isn't a reference 某个页面树子节点不是引用 +@msg a page tree node has no kids 某个页面树节点没有子节点 +@msg an object can't be read 无法读取某个对象 +@msg bad page tree 页面树错误 +@msg bad trailer 文件尾错误 +@msg no catalog 没有目录 +@msg no cross-reference table 没有交叉引用表 +@msg no pages 没有页面 +@msg no plug-in {0} is installed 未安装增效工具 {0} +@msg not a valid plug-in module: {0} 不是有效的增效工具模块:{0} +@msg plug-in ABI: {0} 增效工具 ABI:{0} +@msg plug-in exceeded its {0} 增效工具超出了其 {0} +@msg plug-in failed: {0} 增效工具失败:{0} +@msg plug-in manifest: {0} 增效工具清单:{0} +@msg plug-in output: {0} 增效工具输出:{0} +@msg plug-in parameters: {0} 增效工具参数:{0} +@msg plug-in trapped: {0} 增效工具运行时出错 (trap):{0} +@msg is not an image VectorCraft reads 不是 VectorCraft 可读取的图像 +@msg not found 未找到 +@msg SVG parse error: {0} SVG 解析错误:{0} +@msg decimals must be {_1}–{_2} (got {_3}) 小数位数必须为 {_1}–{_2} (实际为 {_3}) +@msg Exporting {_1} 正在导出 {_1} +@msg Exported {what} 已导出 {what} +@msg "{0}" is not a colour: write it as #rrggbb "{0}" 不是颜色:请写为 #rrggbb +@msg could not decode image: {0} 无法解码图像:{0} +@msg image is empty 图像为空 +@msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) 图像为 {width} × {height} 像素,超出图像描摹的上限 ({max} 百万像素) +@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead 这不是纯色标志 (其实色区域中只有 {_1}% 与少数几种纯色相符),因此“纯色标志”模式会把其中的渐变变成色块:请改用“颜色”模式 +@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset 此描摹将生成超过 {max} 个锚点,超出文档可流畅显示的数量:请提高“杂色”,降低“路径”或“颜色”,或选择保真度较低的预设 +@msg internal error: {msg} (please report this bug) 内部错误:{msg} (请报告此错误) +@msg the background worker stopped 后台工作进程已停止 +@msg {_1} failed: {e} {_1} 失败:{e} +@msg {label}… {label}… +@msg no blend options 没有混合选项 +@msg bad color {v} 颜色 {v} 无效 +@msg the Color Picker is not open 拾色器未打开 +@msg the screen color sampler is already open 屏幕取色器已打开 +@msg this system has no screen color sampler 此系统没有屏幕取色器 +@msg Corners edits the corners of a path 转角用于编辑路径的转角 +@msg give corners of the path: anchors where its sides meet at an angle 请给出路径的转角:其各边以一定角度相交处的锚点 +@msg select one path, or give its `id` 请选择一条路径,或给出其 `id` +@msg the drawing is no longer loaded 该图纸已不再加载 +@msg No saved selections (Select → Save Selection…) 没有已保存的选区 (选择 → 保存选区…) +@msg effect {k} is `{_1}`, not `{id}` 效果 {k} 是 `{_1}`,而不是 `{id}` +@msg no effect at index {k} 索引 {k} 处没有效果 +@msg unknown effect `{id}` 未知效果 `{id}` +@msg `{id}` has no envelope dialog `{id}` 没有封套对话框 +@msg select an envelope 请选择一个封套 +@msg no format to export to 没有可导出的格式 +@msg nothing to export: the document has no visible art (or turn on Use Artboards) 没有可导出的内容:文档中没有可见的图稿 (或请打开“使用画板”) +@msg add a format to export 请添加要导出的格式 +@msg check an asset to export 请勾选要导出的资源 +@msg `{tool}` isn't a freehand tool `{tool}` 不是手绘工具 +@msg bad colour `{hex}` (#rrggbb) 颜色 `{hex}` 无效 (#rrggbb) +@msg no gradient stop or freeform point is selected 未选择渐变色标或任意形状点 +@msg no graphic style `{n}` 没有图形样式 `{n}` +@msg select two or more graphic styles to merge 请选择两个或更多要合并的图形样式 +@msg the PDF is no longer loaded 该 PDF 已不再加载 +@msg no such layer or object 没有此图层或对象 +@msg `{tool}` isn't a Liquify tool `{tool}` 不是液化工具 +@msg {_1} linked file(s) can't be read here: their previews show 此处无法读取 {_1} 个链接文件:显示的是其预览 +@msg {_1} linked image(s) changed on disk: Update Links shows the new versions 有 {_1} 个链接图像已在磁盘上更改:“更新链接”可显示新版本 +@msg pick a colour, gradient or pattern to save as a swatch 请选取要保存为色板的颜色、渐变或图案 +@msg Packaged {n} file(s) in {folder}{note} 已将 {n} 个文件打包到 {folder}{note} +@msg Packaged {n} file(s){note} 已打包 {n} 个文件{note} +@msg choose a location for the package 请为打包选择位置 +@msg missing `folder` 缺少 `folder` +@msg the package came back damaged 返回的打包文件已损坏 +@msg no preset editor 没有预设编辑器 +@msg no perspective grid preset named `{name}` 没有名为 `{name}` 的透视网格预设 +@msg no active perspective plane 没有活动的透视平面 +@msg plane must be left, right or ground plane 必须是 left、right 或 ground +@msg missing `id` (see plugin.list) 缺少 `id` (请参阅 plugin.list) +@msg no plug-in {id} is installed 未安装增效工具 {id} +@msg {_1} is a live effect (Effect › Plug-ins) {_1} 是实时效果 (效果 › 增效工具) +@msg a preset named `{_1}` exists 名为 `{_1}` 的预设已存在 +@msg no print preset named `{base}` 没有名为 `{base}` 的打印预设 +@msg no print preset named `{name}` 没有名为 `{name}` 的打印预设 +@msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) 没有名为 `{name}` 的已保存打印预设 ({DEFAULT_PRESET} 受保护:请以它为基础新建一个) +@msg print settings are not an object 打印设置不是对象 +@msg no colour group `{g}` 没有颜色组 `{g}` +@msg select artwork (or a colour group) to recolor 请选择要重新着色的图稿 (或颜色组) +@msg Downloaded {_1} file(s) 已下载 {_1} 个文件 +@msg Preview in Browser needs the desktop app (the web downloads files instead) “在浏览器中预览”需要桌面应用 (网页版会改为下载文件) +@msg no file system here 此处没有文件系统 +@msg nothing to preview 没有可预览的内容 +@msg Saved {n} files beside {path} 已在 {path} 旁保存 {n} 个文件 +@msg `{_1}` is no save command `{_1}` 不是保存命令 +@msg name the file 请为文件命名 +@msg unknown format 未知格式 +@msg PDF settings are not an object PDF 设置不是对象 +@msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) 没有名为 `{name}` 的已保存 PDF 预设 (内置预设为只读:请以它们为基础新建一个) +@msg Saved to User Defined: {_1} 已保存到“用户定义”:{_1} +@msg choose a direction to divide in 请选择分割方向 +@msg choice must be "merge" or "add", not `{choice}` choice 必须是 "merge" 或 "add",而不是 `{choice}` +@msg `{name}` has no options `{name}` 没有选项 +@msg no swatch `{name}` 没有色板 `{name}` +@msg `{color}` is not a colour (#rrggbb or a preset name) `{color}` 不是颜色 (#rrggbb 或预设名称) +@msg give the path `id` and the width point `index` 请给出路径的 `id` 和宽度点的 `index` +@msg no such width point 没有此宽度点 +@msg onto must name a panel floating in another group onto 必须指定浮动在另一个组中的面板 +@msg below and above must name a panel floating in another group below 和 above 必须指定浮动在另一个组中的面板 +@msg give only one of onto, below and above onto、below 和 above 只能给出一个 +@msg column must be true or a number from 1 column 必须是 true 或从 1 开始的数字 +@msg unknown panel `{raw}` 未知面板 `{raw}` +@msg x and y must both be numbers x 和 y 都必须是数字 +@msg Downloaded {what} 已下载 {what} +@msg Exported {count} file(s) to {folder} 已将 {count} 个文件导出到 {folder} +@msg Imported {what}: {_1} 已导入 {what}:{_1} +@msg Installed plug-in {_1} 已安装增效工具 {_1} +@msg Opened with {_1} note(s): {_2} 已打开,有 {_1} 条注释:{_2} +@msg Saved {path} 已保存 {path} +@msg no app to open files here 此处没有可打开文件的应用 +@msg no data 没有数据 +@msg no document 没有文档 +@msg no file manager here 此处没有文件管理器 +@msg no revert command 没有恢复命令 +@msg the Save PDF dialog didn't open “保存 PDF”对话框未打开 +@msg the document has never been saved 该文档从未保存过 +@msg the export returned unreadable data 导出返回了无法读取的数据 +@msg {status} ({e}) {status} ({e}) +@msg Exported {what} with {_1} note(s): {first} 已导出 {what},有 {_1} 条注释:{first} +@msg Saved {path} with {_1} note(s): {first} 已保存 {path},有 {_1} 条注释:{first} +@msg Couldn't open {name}: {e} 无法打开 {name}:{e} +@msg Couldn't read {name}: {e} 无法读取 {name}:{e} +@msg Internal error: {msg} (please report this bug) 内部错误:{msg} (请报告此错误) +@msg Opened {url} 已打开 {url} +@msg No saved views (View → New View…) 没有已保存的视图 (视图 → 新建视图…) +@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` 必须是其菜单项会打开对话框的命令 (请参阅 ui.menuDialog) +@msg `mode` must be grayscale, rgb, hsb, cmyk or websafe `mode` 必须是 grayscale、rgb、hsb、cmyk 或 websafe +@msg `{key}` must be true or false `{key}` 必须是 true 或 false +@msg brightness must be dark|mediumDark|mediumLight|light brightness 必须是 dark|mediumDark|mediumLight|light +@msg index must be a number or first, previous, next or last index 必须是数字,或 first、previous、next 或 last +@msg index must be a number or first, previous, next or last, not `{other}` index 必须是数字,或 first、previous、next 或 last,而不是 `{other}` +@msg lang must be one of {_1} lang 必须是 {_1} 之一 +@msg missing `name` 缺少 `name` +@msg missing `tool` 缺少 `tool` +@msg missing index 缺少 index +@msg multiple windows land with M11.5 多窗口将在 M11.5 中提供 +@msg no PNG encoder 没有 PNG 编码器 +@msg no document open 没有打开的文档 +@msg no effect applied yet 尚未应用任何效果 +@msg no such perspective grid preset 没有此透视网格预设 +@msg no such recent file 没有此最近使用的文件 +@msg no such recent font 没有此最近使用的字体 +@msg no such saved selection 没有此已保存的选区 +@msg no such user library 没有此用户库 +@msg no such view 没有此视图 +@msg printing isn't available here 此处无法打印 +@msg select an image 请选择一个图像 +@msg the document has no artboards 文档没有画板 +@msg tool (a tool id) is required 需要 tool (工具 id) +@msg unknown tool `{t}` 未知工具 `{t}` +@msg no such action 没有此动作 +@msg choose a folder to export to 请选择要导出到的文件夹 +@msg the selection has no URL (or they differ) 所选对象没有 URL (或其 URL 各不相同) +@msg select some type first 请先选择一些文字 +@msg {_1} font families available 有 {_1} 个可用的字体系列 +@msg {_1} has no {_2} style {_1} 没有 {_2} 样式 +@msg Copied the CSS of {rules} object(s) 已复制 {rules} 个对象的 CSS +@msg Copied the script report 已复制脚本报告 +@msg Exported {path} 已导出 {path} +@msg no CSS to copy: select objects with CSS of their own 没有可复制的 CSS:请选择带有自身 CSS 的对象 +@msg unknown highlight `{h}` 未知的高亮显示 `{h}` +@msg unknown overprints `{o}` (preserve, simulate or discard) 未知的叠印方式 `{o}` (preserve、simulate 或 discard) +@msg unknown preset `{name}` (see flattener.presets.list) 未知预设 `{name}` (请参阅 flattener.presets.list) +@msg Select text or click into text with the Type tool to insert glyphs 请选择文字,或用文字工具在文字中单击,以插入字形 +@msg `library` is a library id or name (see {list}) `library` 是库 id 或名称 (请参阅 {list}) +@msg no library `{key}` (see {list}) 没有库 `{key}` (请参阅 {list}) +@msg select a linked image: an embedded one has no file 请选择链接的图像:嵌入的图像没有文件 +@msg {path} can't be found: relink it first 找不到 {path}:请先重新链接 +@msg Embedded {n} image(s); {missing} missing: relink them first 已嵌入 {n} 张图像;缺少 {missing} 张:请先重新链接 +@msg Relinked {_1} image(s) 已重新链接 {_1} 张图像 +@msg Relinked {n} image(s) 已重新链接 {n} 张图像 +@msg Relinked {n} image(s); not in that folder: {_1} 已重新链接 {n} 张图像;不在该文件夹中:{_1} +@msg Unembedded to {_1} 已取消嵌入到 {_1} +@msg Updated {_1} image(s) 已更新 {_1} 张图像 +@msg no swatch library `{l}` (see swatch.library.list) 没有色板库 `{l}` (请参阅 swatch.library.list) +@msg no user library folder here 此处没有用户库文件夹 +@msg Couldn't place {name}: the document it was dropped on was closed 无法置入 {name}:将它拖放到的文档已关闭 +@msg no files to place 没有要置入的文件 +@msg paths: give at least one file paths:请至少给出一个文件 +@msg Couldn't place {name}: {e} 无法置入 {name}:{e} +@msg no print service 没有打印服务 +@msg the print job has no data 打印作业没有数据 +@msg there are no printer settings to open here 此处没有可打开的打印机设置 +@msg Couldn't save recovery data: {e} 无法保存恢复数据:{e} +@msg Saved recovery data for {title} 已保存 {title} 的恢复数据 +@msg Discarded {_1} recovered document(s) 已丢弃 {_1} 个恢复的文档 +@msg Restored {_1} recovered document(s): save them to keep them 已还原 {_1} 个恢复的文档:请保存以保留它们 +@msg Saving recovery data for {_1} 正在保存 {_1} 的恢复数据 +@msg This shortcut file is in a form VectorCraft can't read. 此快捷键文件的格式 VectorCraft 无法读取。 +@msg bad shortcut 快捷键无效 +@msg can't parse shortcut `{c}` 无法解析快捷键 `{c}` +@msg can't parse shortcut `{s}` for `{k}` 无法解析 `{k}` 的快捷键 `{s}` +@msg no dialog open 没有打开的对话框 +@msg no file reader 没有文件读取器 +@msg no file writer 没有文件写入器 +@msg not a VectorCraft shortcut set (missing `overrides`) 不是 VectorCraft 快捷键组 (缺少 `overrides`) +@msg not a shortcut set: the text doesn't start with a slash name and an opening brace 不是快捷键组:文本不是以斜杠名称和左花括号开头 +@msg shortcut for `{k}` must be a string `{k}` 的快捷键必须是字符串 +@msg tool shortcuts must be a single key (optionally with Shift) 工具快捷键必须是单个键 (可带 Shift) +@msg unknown preset (one of {_1}) 未知预设 (应为 {_1} 之一) +@msg unknown tool or command `{key}` 未知工具或命令 `{key}` +@msg {_1} is already used by {_2} {_1} 已被 {_2} 使用 +@msg Couldn't copy to the system clipboard: {e} 无法复制到系统剪贴板:{e} +@msg Couldn't paste from the clipboard: {e} 无法从剪贴板粘贴:{e} +@msg nothing selected 未选择任何对象 +@msg the {tool} tool has no options {tool} 工具没有选项 +@msg unknown tool `{tool}` 未知工具 `{tool}` +@msg {tool} isn't in a toolbar group of several tools {tool} 不在包含多个工具的工具栏组中 +@msg no such document 没有此文档 +@msg {_1}: not saved, so not closed {_1}:未保存,因此未关闭 +@msg `{name}` is a built-in workspace; choose another name `{name}` 是内置工作区;请选择其他名称 +@msg `{to}` is not an available name `{to}` 不是可用的名称 +@msg no user workspace `{from}` 没有用户工作区 `{from}` +@msg unknown workspace (one of {_1}) 未知工作区 (应为 {_1} 之一) +@msg workspace name can't be empty 工作区名称不能为空 +@msg No fonts are missing in this document 此文档中没有缺失的字体 +@msg VectorCraft doesn't search other apps' or the system's folders: {path} VectorCraft 不会搜索其他应用或系统的文件夹:{path} +@msg VectorCraft doesn't search the Shared or Public folder in Users: {path} VectorCraft 不会搜索“用户”中的 Shared 或 Public 文件夹:{path} +@msg {path} is not a folder {path} 不是文件夹 +@msg the search couldn't start: try again in a moment 无法开始搜索:请稍后重试 +@msg searching a folder isn't available on the web 网页版无法搜索文件夹 +@msg every search thread stopped on an internal error (please report this bug) 所有搜索线程均因内部错误而停止 (请报告此错误) +@msg VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it 此处未设置 VectorCraft 的字体文件夹:桌面应用和 vectorcraft-cli 会设置该文件夹 +@msg choose the font files to add 请选择要添加的字体文件 +@msg {n} font file(s) are now in {folder} {n} 个字体文件现已位于 {folder} +@msg {n} font file(s) are now in {folder}; {failed} couldn't be added: {reason} {n} 个字体文件现已位于 {folder};有 {failed} 个无法添加:{reason} +@msg {n} font(s) aren't available here: their text shows in another font 此处没有 {n} 种字体:使用它们的文字以其他字体显示 +@msg not a file 不是文件 +@msg not a font file 不是字体文件 +@msg that document is no longer open 该文档已不再打开 +@msg larger than 256 MB 大于 256 MB +@msg the files come to more than 1 GB: add fewer at a time 文件总计超过 1 GB:请每次少添加一些 +@msg every numbered name for it is taken in the Fonts folder 字体文件夹中所有带编号的名称均已被占用 +@msg every numbered name for {name} is taken in the document's folder 文档所在文件夹中 {name} 的所有带编号的名称均已被占用 +@msg No linked files to look for 没有要查找的链接文件 +@msg Relinked {n} image(s); {name} couldn't be relinked: {reason} 已重新链接 {n} 张图像;无法重新链接 {name}:{reason} +@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步骤引用 {r},只有 `run` 和 `command_batch` 能解析它 +@msg `ids` must be an array of object ids, not {v} `ids` 必须是由对象 id 组成的数组,而不是 {v} +@msg `corners` must be an array of anchor indices, not {v} `corners` 必须是由锚点索引组成的数组,而不是 {v} +@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`:步骤 {i} 的结果中没有此值 (要表示 $ 本身,请写 $$) +@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`:此步骤之前没有步骤 {i} (步骤从 0 开始计数;要表示 $ 本身,请写 $$) +@msg object ids are non-negative integers, not {v} 对象 id 是非负整数,而不是 {v} +@msg automation filesystem access is not granted: read authority is absent: {path} 未授予自动化文件系统访问权限:缺少读取权限:{path} +@msg automation filesystem access is not granted: write authority is absent: {path} 未授予自动化文件系统访问权限:缺少写入权限:{path} +@msg automation path rejected: outside the read root {root}: {path} 已拒绝自动化路径:位于读取根目录 {root} 之外:{path} +@msg automation path rejected: outside the write root {root}: {path} 已拒绝自动化路径:位于写入根目录 {root} 之外:{path} +@msg automation path rejected: path is empty 已拒绝自动化路径:路径为空 +@msg automation path rejected: a link that leads nowhere: {path} 已拒绝自动化路径:链接指向不存在的位置:{path} +@msg automation path rejected: cannot resolve {path}: {e} 已拒绝自动化路径:无法解析 {path}:{e} +@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} 已拒绝自动化路径:在不存在的文件夹下向上级遍历:{path} +@msg automation path rejected: reserved device names are not allowed: {path} 已拒绝自动化路径:不允许使用保留的设备名称:{path} +@msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode 无法读取多通道 PSD 文件:请以 RGB、CMYK 或灰度模式保存 +@msg not a PSD file 不是 PSD 文件 +@msg the PSD file has an unknown colour mode ({m}) PSD 文件的颜色模式未知 ({m}) +@msg the PSD file's image data uses an unknown compression ({m}) PSD 文件的图像数据使用了未知的压缩方式 ({m}) +@msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 图像过大,无法读取 ({_1} × {_2} 像素) +@msg zipped 32-bit PSD image data with prediction can't be read 无法读取带预测的 ZIP 压缩 32 位 PSD 图像数据 +@msg {depth}-bit PSD files in this colour mode can't be read 无法读取此颜色模式下的 {depth} 位 PSD 文件 +@msg the PSD file is cut short PSD 文件不完整 +@msg the PSD file's header is damaged PSD 文件头已损坏 +@msg the PSD file's image data can't be read: {e} 无法读取 PSD 文件的图像数据:{e} +@msg Imported library {name} 已导入库 {name} +@msg invalid restored panel group 还原的面板组无效 +@msg Invalid panel layout; restored the default dock 面板布局无效;已还原默认停靠 +@msg action is required 需要 action +@msg anchor must name a registered panel anchor 必须指定已注册的面板 +@msg before must name a panel before 必须指定面板 +@msg docking action contains an unknown panel or an excessive split path 停靠操作包含未知面板或过长的拆分路径 +@msg group must be true or false group 必须是 true 或 false +@msg move requires a destination panel 移动需要目标面板 +@msg move requires an existing destination panel 移动需要现有的目标面板 +@msg onto must name a floating panel onto 必须指定浮动面板 +@msg panel group is empty 面板组为空 +@msg panel must name a registered panel panel 必须指定已注册的面板 +@msg saved panel layout is invalid; reset the workspace first 保存的面板布局无效;请先重置工作区 +@msg x and y must be provided together x 和 y 必须同时提供 +@msg zone must be a string zone 必须是字符串 +@msg zone must be tab, center, left, right, top or bottom zone 必须是 tab、center、left、right、top 或 bottom +@msg {key} must be a finite coordinate {key} 必须是有限坐标 +@msg invalid native panel set 原生面板集无效 +@msg missing panel layout 缺少面板布局 +@msg unsupported native docking operation 不支持的原生停靠操作 +@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) 压缩文件解压后超出 VectorCraft 的读取上限 (文档 {_1} MB,每张图像 {_2} MB) +@msg the document is too large to save compressed ({0}): save it with Use Compression off 文档过大,无法压缩保存 ({0}):请关闭“使用压缩”后保存 +@msg the file stopped being read (please report this bug) 文件读取已中断 (请报告此错误) +@msg the image can't be read as a texture 无法将图像作为纹理读取 +@msg give a fraction `t` from 0 to 1 请给出 0 到 1 之间的比例 `t` +@msg give the path `id` 请给出路径的 `id` +@msg invalid stroke widths 描边宽度无效 +@msg object has no weighted stroke 对象没有带粗细的描边 +@msg Angles require two straight segments 角度需要两条直线段 +@msg Choose two different points 请选择两个不同的点 +@msg Choose two non-parallel straight segments 请选择两条不平行的直线段 +@msg Choose two nonzero angle arms 请选择两条长度不为零的角边 +@msg Dimension point is outside the canvas 尺寸标注点位于画布之外 +@msg Dimension size is outside its supported range 尺寸标注大小超出支持的范围 +@msg First measured segment no longer exists 第一条测量线段已不存在 +@msg Invalid dimension distance 尺寸标注距离无效 +@msg Invalid dimension font name 尺寸标注字体名称无效 +@msg Invalid drawing scale 图纸比例无效 +@msg Invalid real scale 实际比例无效 +@msg Measured anchor no longer exists 测量锚点已不存在 +@msg Measured segment no longer exists 测量线段已不存在 +@msg Non-finite dimension 尺寸标注不是有限值 +@msg Scale must be drawing:real, for example 1:10 比例必须为 图纸:实际,例如 1:10 +@msg Scale ratio must be between 1:1000000 and 1000000:1 比例必须介于 1:1000000 和 1000000:1 之间 +@msg Second measured segment no longer exists 第二条测量线段已不存在 +@msg Unknown dimension mode 未知的尺寸标注模式 +@msg Unknown dimension option {k} 未知的尺寸标注选项 {k} +@msg span must be "object" or "selection", got {v} span 必须是 "object" 或 "selection",实际为 {v} +@msg span: "selection" places a `gradient`, not a swatch span: "selection" 放置的是 `gradient`,而不是色板 +@msg span: "selection" places a gradient: give `gradient` span: "selection" 放置渐变:请给出 `gradient` +@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" 放置线性或径向渐变,而不是任意形状渐变 +@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" 自行放置渐变:请省略 `start` 和 `end` (它们已按文档坐标放置渐变) +@msg Batch: {count} processed 批处理:已处理 {count} 个 +@msg Batch: {count} processed, {failed} failed 批处理:已处理 {count} 个,失败 {failed} 个 +@msg choose a destination folder 请选择目标文件夹 +@msg choose a source folder 请选择源文件夹 +@msg open a document with data sets 请打开包含数据集的文档 +@msg the action has no steps 该动作没有步骤 # Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands. Import Shortcut Set 导入快捷键组 Import Shortcut Set… 导入快捷键组… diff --git a/docs/development.md b/docs/development.md index 7627cbfb2..094f6f692 100644 --- a/docs/development.md +++ b/docs/development.md @@ -233,7 +233,7 @@ other `sv-*` locale resolve to it), Czech (`cs`, every menu label) and Brazilian control channel, MCP and tests read them) and are translated only where the status bar draws them (`i18n::msg`). `@msg` catalog rows hold a whole message or a template such as `Couldn't open {name}: {e}`; `{_1}`, `{_2}` … stand for the format string's `{}`, and the values in the - placeholders are translated in turn (the reason after `: {e}` is often a message too). Czech, German, Spanish, French, Hungarian, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Vietnamese and Finnish cover every + placeholders are translated in turn (the reason after `: {e}` is often a message too). Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Swedish, Ukrainian, Vietnamese and Finnish cover every message literal the test scan finds (`complete_languages_translate_every_message`, languages listed in `COMPLETE_MESSAGES`): a new `Err("…")`, `Other(…)`, `#[error(…)]` or `status(…)` message needs a row in each of those catalogs (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui complete_languages_translate_every_message` lists them all). Other languages show messages in English diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d44b53f02..e632aef62 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -24,7 +24,7 @@ Chinese by `imachus`). None is recorded as reviewed by a native speaker; the cat | Language | Code | UI strings translated | Dialogs / tooltips / messages | Script support | Native review | Status | To `full` (h) | |---|---|---|---|---|---|---|---| | English | `en` | source, 100% | all | Latin | — | full | — | -| Simplified Chinese (Mandarin) | `zh-hans` | 3,647 rows; menus, panels, dialogs complete (tests); 85% overall | messages in English (1 of 604) | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | partial | 2–3 | +| Simplified Chinese (Mandarin) | `zh-hans` | 4,761 rows, 714 of them `@msg` (counted 2026-10-16): menus, `tl!` literals and messages complete (tests) | all | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | full | — (review) | | Spanish | `es` | 4,009 / 4,020 (99.7%) | all (604 messages) | Latin | no | full | — (review) | | Hindi | `hi` | 0 | none | Devanagari: the document's text shapes (HarfRust), with tests of conjuncts, reph, pre-base matras, nukta forms and caret stops that run when an OpenType Devanagari font is installed, but the egui interface does no complex shaping | no | none | 20–30 | | Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a3d40fa66..51e614bda 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -565,7 +565,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur For Auto Size area type in columns, the bisection for the frame's height runs on the lines of the one-column layout, and its result is checked with at most two layouts of the columns. When the text wraps around objects or does not fit the one-column layout, or when the check fails, the columns are laid out at every step of the bisection, as before. Typing at the start of a story of 50,000 characters in 2 columns took 1,670 ms a keystroke and now takes 292 ms (release build, `auto_height_in_columns_typing_timing`). An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`). The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. -- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech every menu label; German, Spanish, French, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). +- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286). Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set. - **Image Trace, thin closed outlines:** generated `fit::snap_area_tests` verify nonzero interiors after straightening, opposite closed-boundary winding and Noise controls; see [quality notes](image-trace-quality.md#thin-closed-outlines-with-snap-curves-to-lines). Scores and hours unchanged; open-piece fitting is unchanged. From 8511d0321bee2434fcfb8e475b12b851a31c01bf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 16:17:15 -0700 Subject: [PATCH 085/208] SVG export: justified and Mojikumi lines keep their letter spacing in live text SVG export wrote each style run of a line of live text as one and positioned the text again only after tabs and, on justified lines, after word spaces. A viewer then spaced the characters inside the with the font's own advances, so it lost the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line and the spacing that a Mojikumi Set changes. On justified lines, the textLength of horizontally scaled type also took in the justification space after it, so a viewer stretched the glyphs into that space. On a line that is justified or set with a Mojikumi Set, text_lines now lays the text out again as point type without the Mojikumi Set to get the advance a viewer gives each character. Each character that the layout draws away from where a viewer would draw it starts a new positioned , and textLength and line anchors use the viewer's widths. The last character of a horizontally scaled takes its width without the font's kerning against the next character. Other lines, and lines written with Fewer Elements, are written as before. Docs: docs/target-app-parity.md. --- crates/svg/src/export.rs | 118 ++++++++++++++++++--- crates/svg/tests/text_layout.rs | 178 ++++++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 284 insertions(+), 13 deletions(-) diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index b51dffe43..b6e192410 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1941,7 +1941,7 @@ impl Writer<'_> { /// One line of laid-out text starting at `start`. Default: a positioned `` per segment /// (only the first of an anchored line). Fewer tspans: one positioned `` per line with - /// the style changes nested in it, positioned again only after tabs and justified word spaces. + /// the style changes nested in it, positioned again only after tabs and inline graphics. /// Baseline shifts are relative (`dy`), undone by the next segment that isn't shifted. /// `positioned`: false when the caller already positioned the line (an outer ``). fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase, positioned: bool) { @@ -1966,7 +1966,8 @@ impl Writer<'_> { attrs.push_str(&format!(" dy=\"{}\"", self.num(shift - st.baseline_shift))); shift = st.baseline_shift; } - // After a tab or a justified word space the next segment is placed. + // After a tab, an inline graphic or a justified word space, and before a character that a + // reader would draw elsewhere, the next segment is placed. place = seg.brk || (!fewer && !base.anchored && positioned); if (st.h_scale - st.v_scale).abs() > 1e-9 { attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance))); @@ -2164,26 +2165,49 @@ struct Line { struct Segment { /// Index into `TextObject::runs`. run: usize, - /// Where the segment starts (text space; `y` is the baseline). + /// Where a reader starts drawing the segment (text space; `y` is the baseline). x: f64, y: f64, text: String, - /// The laid-out width (tracking and justification included). + /// The width a reader gives the segment (tracking included). On a line that is justified or + /// set with Mojikumi (unless `fewer`), that is the width without justification and Mojikumi + /// ([`reader_advances`]), for horizontally scaled type also without its last character's + /// kerning against the character after it ([`end_segment`]); elsewhere, the laid-out width. advance: f64, - /// The next segment needs its own position (after a tab, or a word space of a justified line). + /// The next segment needs its own position (after a tab, a word space of a justified line, or + /// the next segment starts with a character the layout draws elsewhere than a reader would). brk: bool, } +/// How far a reader may draw a character from where the layout draws it before the character is +/// given its own position (points). +const PLACE_TOLERANCE: f64 = 1e-6; + /// The laid-out lines of `t` as segments: a new segment at every style change, after tabs and, /// on justified lines, after each word space (unless `fewer`). The text comes from the clusters /// the layout placed: soft hyphens draw nothing, a line broken by hyphenation ends in `-`, all -/// caps are upper case. +/// caps are upper case. Unless `fewer`, a line that is justified or composed with Mojikumi also +/// starts a new segment at each character the layout draws elsewhere than a reader continuing +/// the segment would ([`reader_advances`]): a Justify All line's letter gaps, the gaps between +/// the characters of a justified Japanese line, and the spacing Mojikumi changes. fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec { let plain = t.plain_text(); let mut lines = Vec::with_capacity(lay.lines.len()); + let (mut kerned, mut unkerned) = (None, None); for line in &lay.lines { let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count()); - let split_words = !fewer && !matches!(t.para_at(para).justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); + let style = t.para_at(para); + let split_words = !fewer && !matches!(style.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); + // Each cluster's advance and drawing offset as a reader gives them, by its byte offset. + let reader = if split_words || (!fewer && !style.mojikumi.is_none()) { + Some(&*kerned.get_or_insert_with(|| reader_advances(t, lay, true))) + } else { + None + }; + // Where a reader draws the next character of the last segment, and the byte offset and + // reader's advance of the last segment's last cluster. + let mut pen = 0.0; + let mut tail = None; let mut segs: Vec = Vec::new(); let mut cluster = None; let source = |g: &vectorcraft_text::PositionedGlyph| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }; @@ -2206,26 +2230,51 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - cluster = None; continue; } + // The cluster as a reader sets it (a hyphen the layout adds is set as laid out). + let set = reader.and_then(|r| r.get(&g.byte)).filter(|_| g.len > 0); if g.len > 0 && cluster == Some(g.byte) { - // Another glyph of the same cluster. - if let Some(last) = segs.last_mut() { + // Another glyph of the same cluster (a reader's advance covers the whole cluster). + if let Some(last) = segs.last_mut().filter(|_| set.is_none()) { last.advance += g.advance; + pen += g.advance; } continue; } cluster = Some(g.byte); + let (advance, x) = match set { + Some(&(advance, offset)) => { + // A glyph the layout draws before its pen (Mojikumi) starts that much earlier. + let x = g.xf.translation().x - offset; + (advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x }) + } + None => (g.advance, g.origin.x), + }; let src: String = source(g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect(); let piece = if run.style.all_caps { src.to_uppercase() } else { src }; let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace)); let fresh = std::mem::take(&mut after_inline); + // The layout draws the character elsewhere than a reader continuing the line would. + let moved = reader.is_some() + && segs.last().is_some_and(|last| (x - pen).abs() > PLACE_TOLERANCE || (g.origin.y - last.y).abs() > PLACE_TOLERANCE); + pen = x + advance; match segs.last_mut() { - Some(last) if last.run == g.run && !last.brk && !fresh => { + Some(last) if last.run == g.run && !last.brk && !fresh && !moved => { last.text.push_str(&piece); - last.advance += g.advance; + last.advance += advance; last.brk = brk; } - _ => segs.push(Segment { run: g.run, x: g.origin.x, y: g.origin.y, text: piece, advance: g.advance, brk }), + last => { + if let Some(last) = last { + last.brk |= moved; + end_segment(last, tail, t, lay, &mut unkerned); + } + segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk }); + } } + tail = set.map(|_| (g.byte, advance)); + } + if let Some(last) = segs.last_mut() { + end_segment(last, tail, t, lay, &mut unkerned); } segs.retain(|s| !s.text.is_empty()); if !segs.is_empty() { @@ -2235,7 +2284,50 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - lines } -/// Where a line of segments starts: its first segment's origin or, `anchor`ed (0.5: centre, 1: +/// Ends segment `seg` of horizontally scaled type, which a reader stretches to its width +/// (`textLength`): its last cluster (`tail`: byte offset and reader's advance) takes the advance +/// a reader gives it at the end of a ``, without the font's kerning against the character +/// after it. `unkerned` holds [`reader_advances`] without kerning once a segment needs them. +fn end_segment( + seg: &mut Segment, + tail: Option<(usize, f64)>, + t: &TextObject, + lay: &vectorcraft_text::TextLayout, + unkerned: &mut Option>, +) { + let Some((byte, advance)) = tail else { return }; + if t.runs.get(seg.run).is_none_or(|r| (r.style.h_scale - r.style.v_scale).abs() <= 1e-9) { + return; + } + if let Some(&(alone, _)) = unkerned.get_or_insert_with(|| reader_advances(t, lay, false)).get(&byte) { + seg.advance += alone - advance; + } +} + +/// The advance a reader of live SVG text gives each character cluster of `t` and how far from +/// its pen it draws the cluster's first glyph, keyed by the cluster's byte offset: `t` laid out +/// as point type (which isn't justified) without Mojikumi, scaled as `t`'s layout `lay` is by +/// Shrink Text to Fit. Without `kerning`, the font's pair kerning is left out (as manual +/// kerning leaves it out). +fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: bool) -> HashMap { + let mut set = TextObject { kind: TextKind::Point, ..t.clone() }; + for p in std::iter::once(&mut set.para).chain(set.paras.iter_mut()) { + p.mojikumi = vectorcraft_doc::Mojikumi::None; + } + if !kerning { + for r in &mut set.runs { + r.style.kerning.get_or_insert(0.0); + } + } + let mut out: HashMap = HashMap::new(); + for g in vectorcraft_text::layout(FontDb::global(), &set).glyphs.iter().filter(|g| g.len > 0) { + let (advance, _) = out.entry(g.byte).or_insert((0.0, (g.xf.translation().x - g.origin.x) * lay.fit_scale)); + *advance += g.advance * lay.fit_scale; + } + out +} + +/// Where a line of segments starts: where its first segment starts or, `anchor`ed (0.5: center, 1: /// right end), that point of its width. `None` for an empty line. fn line_start(line: &[Segment], anchor: Option) -> Option<(f64, f64)> { let (first, last) = (line.first()?, line.last()?); diff --git a/crates/svg/tests/text_layout.rs b/crates/svg/tests/text_layout.rs index 3735e7e6e..2ee9e3232 100644 --- a/crates/svg/tests/text_layout.rs +++ b/crates/svg/tests/text_layout.rs @@ -158,6 +158,184 @@ fn justified_lines_place_each_word() { assert!(words >= 3, "{svg}"); } +/// The only type in `d`. +fn the_text(d: &Document) -> TextObject { + d.layers[0].children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some((**t).clone()) } else { None }).unwrap() +} + +/// A drawn character: its text, where its first glyph is drawn, and the glyph's horizontal scale +/// (font units to user space). +type Drawn = (String, Point, f64); + +/// Each character a usvg draws from `svg` (with the bundled font and the font files of `faces`) +/// and how it draws the character's first glyph, in SVG user space and text order; spaces and +/// tabs are left out. +fn viewer_glyph_positions(svg: &str, faces: &[Arc]) -> Vec { + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSans3-Regular.ttf")).unwrap()); + for f in faces { + db.load_font_data(f.file_data().to_vec()); + } + let opt = usvg::Options { fontdb: Arc::new(db), ..Default::default() }; + let tree = usvg::Tree::from_str(svg, &opt).unwrap(); + fn walk(g: &usvg::Group, out: &mut Vec) { + for n in g.children() { + match n { + usvg::Node::Group(g) => walk(g, out), + usvg::Node::Text(t) => { + for g in t.layouted().iter().flat_map(|span| &span.positioned_glyphs) { + if g.text.trim().is_empty() { + continue; + } + let m = t.abs_transform().pre_concat(g.transform()); + out.push((g.text.clone(), Point::new(m.tx as f64, m.ty as f64), m.sx as f64)); + } + } + _ => {} + } + } + } + let mut out = vec![]; + walk(tree.root(), &mut out); + out +} + +/// Each character the canvas draws of the only type in `d` and how it draws the character's +/// first glyph, in document space and text order (spaces and tabs left out), with the faces +/// it draws them in. +fn canvas_glyph_positions(d: &Document) -> (Vec, Vec>) { + let t = the_text(d); + let db = vectorcraft_text::FontDb::global(); + let lay = vectorcraft_text::layout(db, &t); + let plain = t.plain_text(); + let mut out: Vec = vec![]; + let mut faces: Vec> = vec![]; + let mut last = None; + for g in &lay.glyphs { + let text = if g.len == 0 { "-" } else { &plain[g.byte..g.byte + g.len] }; + if text.trim().is_empty() || (g.len > 0 && last == Some(g.byte)) { + continue; + } + last = Some(g.byte); + if let Some(f) = db.face_by_id(g.font_id).filter(|f| !faces.iter().any(|o| o.id() == f.id())) { + faces.push(f); + } + let m = t.xf * g.xf; + out.push((text.to_string(), m * Point::ZERO, m.as_coeffs()[0])); + } + (out, faces) +} + +/// Asserts that a viewer draws every character of `d`'s live SVG text where the canvas draws it, +/// as wide as the canvas draws it. +fn assert_drawn_in_place(d: &Document) { + let svg = export(d, &ExportOptions::default()); + let (canvas, faces) = canvas_glyph_positions(d); + let viewer = viewer_glyph_positions(&svg, &faces); + let chars = |v: &[Drawn]| v.iter().map(|(s, _, _)| s.as_str()).collect::(); + assert_eq!(chars(&viewer), chars(&canvas), "{svg}"); + for ((s, v, vs), (_, c, cs)) in viewer.iter().zip(&canvas) { + assert!((v.x - c.x).abs() < 0.05 && (v.y - c.y).abs() < 0.05, "{s}: the viewer draws it at {v:?}, the canvas at {c:?}: {svg}"); + assert!((vs / cs - 1.0).abs() < 0.002, "{s}: the viewer scales it by {vs}, the canvas by {cs}: {svg}"); + } +} + +/// A Justify All line without word spaces spreads its letters; live text keeps the gaps, with +/// tracking and manual kerning too. +#[test] +fn justify_all_lines_keep_their_letter_gaps() { + let tracked = CharStyle { tracking: 80.0, ..style(12.0) }; + let kerned = CharStyle { kerning: Some(-40.0), ..style(12.0) }; + for runs in [vec![run("abcdefgh", style(12.0))], vec![run("abc", style(12.0)), run("def", tracked), run("ghi", kerned)]] { + let d = area_doc(runs, 200.0, Justify::JustifyAll); + assert_drawn_in_place(&d); + } +} + +/// Justified and Mojikumi lines whose characters a reader draws where the layout does are written +/// as before: a segment per style run, and per word on a justified line. +#[test] +fn lines_without_moved_characters_are_written_as_before() { + let text = |d: &Document| { + let svg = export(d, &ExportOptions::default()); + svg[svg.find("").unwrap()].to_string() + }; + let justified = area_doc(vec![run("aa bb cc dd ee ff", style(12.0))], 60.0, Justify::JustifyLeft); + let mut composed = area_doc(vec![run("Latin words only", style(12.0))], 200.0, Justify::Center); + let id = composed.layers[0].children().unwrap()[0].id; + if let Some(NodeKind::Text(t)) = composed.node_mut(id).map(|n| &mut n.kind) { + t.para.mojikumi = vectorcraft_doc::Mojikumi::LineEndHalf; + } + let open = |x: &str, anchor: &str| { + format!( + r##""## + ) + }; + let words = + [("aa ", "0", "12"), ("bb ", "15.636", "12"), ("cc ", "32.448", "12"), ("dd", "46.68", "12"), ("ee ", "0", "26.4"), ("ff", "14.304", "26.4")]; + let words: String = words.iter().map(|(w, x, y)| format!(r#"{w}"#)).collect(); + assert_eq!(text(&justified), format!("{}{words}", open("0", ""))); + let centered = format!(r#"{}Latin words only"#, open("100", r#" text-anchor="middle""#)); + assert_eq!(text(&composed), centered); +} + +/// Type scaled horizontally on a justified line is stretched to its own width, not to the +/// justification space after it, nor to the font's kerning against the letter after it when +/// the letters are spread (AV is a kerned pair). +#[test] +fn justified_lines_keep_scaled_type_at_its_width() { + let wide = CharStyle { h_scale: 150.0, ..style(12.0) }; + for justify in [Justify::JustifyLeft, Justify::JustifyAll] { + assert_drawn_in_place(&area_doc(vec![run("aa bb cc dd ee ff gg hh ii jj kk ll", wide.clone())], 120.0, justify)); + assert_drawn_in_place(&area_doc(vec![run("abcdefgh", wide.clone())], 200.0, justify)); + } + assert_drawn_in_place(&area_doc(vec![run("AVAVAVAV", wide)], 200.0, Justify::JustifyAll)); +} + +/// On lines that aren't spread, the spacing a reader applies itself (tracking, manual kerning, +/// horizontal scale, baseline shift) puts each character where the canvas draws it. +#[test] +fn ragged_lines_keep_their_character_spacing() { + let tracked = CharStyle { tracking: 80.0, ..style(12.0) }; + let kerned = CharStyle { kerning: Some(-40.0), ..style(12.0) }; + let wide = CharStyle { h_scale: 150.0, ..style(12.0) }; + let shifted = CharStyle { baseline_shift: 3.0, ..style(12.0) }; + for justify in [Justify::Left, Justify::Center, Justify::Right, Justify::JustifyLeft] { + let mut runs = + vec![run("plain ", style(12.0)), run("tracked ", tracked.clone()), run("kerned ", kerned.clone()), run("up ", shifted.clone())]; + // usvg anchors a centered or right-aligned line by its width before `textLength` stretches it. + if matches!(justify, Justify::Left | Justify::JustifyLeft) { + runs.push(run("wide ", wide.clone())); + } + runs.push(run("end", style(12.0))); + assert_drawn_in_place(&area_doc(runs, 280.0, justify)); + } +} + +/// Justified Japanese lines spread between their characters (#344); live text keeps the gaps. +#[test] +fn justified_japanese_lines_keep_the_gaps_between_their_characters() { + let text = "日本語の文章は文字と文字の間で両端を揃えます。この段落は三行になります。"; + for justify in [Justify::JustifyLeft, Justify::JustifyAll] { + assert_drawn_in_place(&area_doc(vec![run(text, style(12.0))], 100.0, justify)); + } +} + +/// Japanese composition (Mojikumi) closes up consecutive punctuation and adds a quarter em between +/// Japanese and Latin letters; live text keeps that spacing, on anchored lines too. +#[test] +fn japanese_composition_keeps_its_spacing() { + let text = "「かっこ」、「かな」。漢字とLatinの間。"; + for justify in [Justify::Left, Justify::Center, Justify::Right] { + let mut d = area_doc(vec![run(text, style(12.0))], 280.0, justify); + let id = d.layers[0].children().unwrap()[0].id; + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.para.mojikumi = vectorcraft_doc::Mojikumi::LineEndHalf; + } + assert_drawn_in_place(&d); + } +} + /// Glyph outlines of every text a usvg (with the bundled font) lays out from `svg`, in SVG /// user space. fn viewer_outlines(svg: &str) -> Vec { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 51e614bda..8d2627cee 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -612,6 +612,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs and the images in an `.ai` or EPS file's editing data, an alpha channel coming in as a grayscale opacity mask) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the ` Result<(Document, Vec, /// The `data-*` attributes of elements, by id ([`user_data`]). data: HashMap>, + /// The event attributes of elements, by id ([`user_events`]). + events: HashMap>, /// Ids of the `` elements marked as layers ([`marked_layer`]). sublayers: HashSet, /// The groups made of them, made sublayers where they sit in a layer ([`promote_sublayers`]). @@ -488,6 +493,10 @@ struct Found { non_scaling: HashSet, /// The `data-*` attributes of elements, by id ([`user_data`]). data: HashMap>, + /// The event attributes of elements, by id ([`user_events`]). + events: HashMap>, + /// The JavaScript files ` + + "#); + let (doc, warnings) = import_with(&text, &ImportOptions { folder: Some("/tmp"), read: Some(&read) }).unwrap(); + let asked = asked.lock().unwrap().clone(); + assert!(asked.iter().any(|p| p.contains("photo.png")) && !asked.iter().any(|p| p.contains("evil")), "read {asked:?}"); + assert_eq!(doc.svg_scripts, ["evil.js"], "each link once, the inline script left out"); + assert!(warnings.iter().any(|w| w.contains("inline scripts were left out")), "{warnings:?}"); + assert_eq!(events(&doc, "shape"), [pair("onclick", "fetch('https://example.com')")]); + assert_eq!(doc.title, "Untitled-1", "the inline script didn't run"); +} +/// Import keeps the first 64 different `