From c1f7e2eeafd24890fbdb22d01ac0bbf52b2e1fd8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:44:32 -0800 Subject: [PATCH 01/34] Stroke: custom arrowheads from an arrowheads file join the arrowhead menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stroke panel menu's Edit Arrowheads… (stroke.arrowheads.edit) opens Arrowheads.vectorcraft next to the preferences, creating it with one example head when it is missing. Each symbol in the file is one head, listed by name after the built-in heads in both arrowhead menus: it points right, its registration point is the tip, and its height is drawn as wide as the built-in heads. The file is read at startup and again each time VectorCraft saves it; stroke.arrowheads.reload and stroke.arrowheads.list read and list it. Arrowhead gets a Custom variant, appended after the built-in names, that carries the head's name and outline, so a document that uses one draws, saves, reopens and exports it without the arrowheads file. stroke.set takes {"custom": name} or the {"Custom": {name, path}} form that document.inspect reports. Part of #5421. --- apps/vectorcraft-cli/src/main.rs | 1 + apps/vectorcraft/src/main.rs | 2 + apps/vectorcraft/src/prefs_dir.rs | 8 + crates/doc/src/appearance.rs | 2 +- crates/doc/src/appearance/layers.rs | 6 +- crates/doc/src/appearance/stroke_style.rs | 101 ++++- crates/doc/src/appearance/tests.rs | 47 +- crates/doc/src/lib.rs | 4 +- crates/doc/src/reach.rs | 6 +- crates/effects/src/stroke/arrow.rs | 28 +- crates/effects/src/stroke/tests.rs | 10 +- crates/effects/src/stroke/tests_arrows.rs | 31 +- crates/effects/src/stroke/tests_reach.rs | 17 +- crates/engine/src/cmd.rs | 2 + crates/engine/src/cmd/arrowheads.rs | 423 ++++++++++++++++++ crates/engine/src/cmd/dimension.rs | 2 +- crates/engine/src/cmd/fileio/save.rs | 5 + crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/src/cmd/stroke.rs | 31 +- crates/engine/src/lib.rs | 2 + crates/engine/src/session/documents.rs | 1 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/arrowheads.rs | 252 +++++++++++ crates/engine/src/tests/panelcmds.rs | 2 +- crates/engine/src/tests/strokegeom.rs | 2 +- crates/pdf/src/tests/stroke.rs | 2 +- crates/render/tests/it/golden.rs | 2 +- crates/render/tests/it/stroke_geometry.rs | 2 +- crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/bs.tsv | 4 + crates/ui-egui/src/i18n/ca.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/da.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fi.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hi.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ka.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/panels/stroke.rs | 65 ++- .../ui-egui/src/panels/tests_strokedepth.rs | 10 +- docs/mcp.md | 25 ++ docs/ui-parity.md | 1 + 59 files changed, 1143 insertions(+), 59 deletions(-) create mode 100644 crates/engine/src/cmd/arrowheads.rs create mode 100644 crates/engine/src/tests/arrowheads.rs diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 57ab103590..8c4c4fffa2 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -246,6 +246,7 @@ fn with_stores(mut h: Headless) -> Headless { prefs_dir::attach_plugin_store(&mut h.session); prefs_dir::attach_pdf_preset_store(&mut h.session); prefs_dir::attach_custom_dictionary(&mut h.session); + prefs_dir::attach_arrowheads(&mut h.session); h } diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index bb94bad1cc..b3b465b6c8 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -474,6 +474,8 @@ fn main() -> std::process::ExitCode { prefs_dir::attach_pdf_preset_store(&mut app.session); // The custom dictionary's words, kept in their own file next to the preferences. prefs_dir::attach_custom_dictionary(&mut app.session); + // The custom arrowheads, kept in their own file next to the preferences. + prefs_dir::attach_arrowheads(&mut app.session); // Fit the window to its monitor, or put it back where it was (still hidden). let winit_window = cc.winit_window().cloned(); if let Some(w) = &winit_window { diff --git a/apps/vectorcraft/src/prefs_dir.rs b/apps/vectorcraft/src/prefs_dir.rs index 96e09f5e78..b4c0a41103 100644 --- a/apps/vectorcraft/src/prefs_dir.rs +++ b/apps/vectorcraft/src/prefs_dir.rs @@ -81,6 +81,14 @@ pub fn attach_custom_dictionary(session: &mut vectorcraft_engine::Session) { } } +/// Keep the custom arrowheads (Stroke panel menu › Edit Arrowheads…) in the `Arrowheads.vectorcraft` +/// file next to the settings, and list the heads it holds. +pub fn attach_arrowheads(session: &mut vectorcraft_engine::Session) { + if let Some(path) = store_dir(vectorcraft_engine::cmd::arrowheads::FILE_NAME) { + session.attach_arrowheads_file(path.to_string_lossy().to_string()); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 2e19ad44a3..3a37e4ee57 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -10,7 +10,7 @@ mod stroke_style; pub use layers::{Effect, FillLayer, StrokeGradientMode, StrokeLayer, stroke_paint_bounds}; pub use profile::{AngleProfile, PressureProfile, ProfilePreset, SavedProfile, WidthProfile}; -pub use stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign}; +pub use stroke_style::{ArrowAlign, Arrowhead, CustomArrowhead, Dash, LineCap, LineJoin, StrokeAlign}; /// One entry of the appearance stack. // A stroke takes 328 bytes and a fill 104; an appearance holds a few of each. diff --git a/crates/doc/src/appearance/layers.rs b/crates/doc/src/appearance/layers.rs index 923275999b..f8a58758d8 100644 --- a/crates/doc/src/appearance/layers.rs +++ b/crates/doc/src/appearance/layers.rs @@ -193,8 +193,10 @@ impl StrokeLayer { /// How far the start (`end == false`) or end arrowhead can reach from its end point (0 /// without one): the head's diagonal, or with [`ArrowAlign::Extend`] its length, plus the cap. pub fn head_reach(&self, end: bool) -> f64 { - let head = if end { self.end_arrow } else { self.start_arrow }; - head.map_or(0.0, |k| k.reach() * self.arrow_weight(end) + self.width / 2.0) + let head = if end { &self.end_arrow } else { &self.start_arrow }; + // A custom head can paint in front of its tip, which Extend puts past the end point. + let reach = |k: &Arrowhead| if self.arrow_align == ArrowAlign::Extend { k.reach().max(k.length()) } else { k.reach() }; + head.as_ref().map_or(0.0, |k| reach(k) * self.arrow_weight(end) + self.width / 2.0) } /// How far the arrowheads can reach from the path's end points (0 without heads). pub fn arrow_reach(&self) -> f64 { diff --git a/crates/doc/src/appearance/stroke_style.rs b/crates/doc/src/appearance/stroke_style.rs index 46a0c30c33..e029f8bc27 100644 --- a/crates/doc/src/appearance/stroke_style.rs +++ b/crates/doc/src/appearance/stroke_style.rs @@ -1,3 +1,7 @@ +use std::sync::Arc; + +use vectorcraft_geom::{PathData, Rect}; + use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -53,8 +57,72 @@ impl Dash { /// (the open arrow's arms are cut square a little behind the box). const HEAD_REACH: f64 = 4.6; +/// An arrowhead the user drew (Stroke panel menu › Edit Arrowheads…), carried with its art so a +/// document that uses it draws it wherever it opens. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(from = "CustomArrowheadData")] +pub struct CustomArrowhead { + /// The name the arrowhead menus show (the symbol's name in the arrowheads file). + pub name: String, + /// The filled outline (non-zero rule) in head units: x from the tip (0) back along the line, y + /// across it, positive to the right of the direction the head points. A head is 4 units wide, + /// as the built-in heads are, and a unit is the head's weight. + pub path: PathData, +} + +#[derive(Deserialize)] +struct CustomArrowheadData { + name: String, + #[serde(default)] + path: PathData, +} + +impl From for CustomArrowhead { + fn from(d: CustomArrowheadData) -> Self { + CustomArrowhead::new(d.name, d.path) + } +} + +impl CustomArrowhead { + /// The most anchors a custom head's outline may have. + pub const MAX_ANCHORS: usize = 4096; + /// How far from the tip a custom head's outline may reach, in head units. + pub const MAX_UNITS: f64 = 64.0; + + /// Head `name` with outline `path` (in head units). An outline with more than + /// [`Self::MAX_ANCHORS`] anchors, a point that is not finite, or a point farther than + /// [`Self::MAX_UNITS`] from the tip is dropped: the head then draws nothing. + pub fn new(name: String, path: PathData) -> Self { + let path = if Self::fits(&path) { path } else { PathData::default() }; + CustomArrowhead { name, path } + } + /// Whether outline `path` is within [`Self::MAX_ANCHORS`] and [`Self::MAX_UNITS`], with finite + /// points only. + pub fn fits(path: &PathData) -> bool { + let anchors = path.subpaths.iter().map(|s| s.anchors.len()).sum::(); + let near = |p: vectorcraft_geom::Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= Self::MAX_UNITS && p.y.abs() <= Self::MAX_UNITS; + anchors <= Self::MAX_ANCHORS && path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| near(a.p) && near(a.h_in) && near(a.h_out)) + } + fn bounds(&self) -> Option { + self.path.control_bounds() + } + /// How far any part of the head can be from its tip, in head units. + pub fn reach(&self) -> f64 { + self.bounds().map_or(0.0, |b| b.x0.hypot(b.y0).max(b.x1.hypot(b.y0)).max(b.x0.hypot(b.y1)).max(b.x1.hypot(b.y1))) + } + /// How far the head spreads either side of its line, in head units. + pub fn half_width(&self) -> f64 { + self.bounds().map_or(0.0, |b| b.y0.abs().max(b.y1.abs())) + } + /// Where the stroke ends under the head, in head units back from the tip: halfway along the + /// head's length. + pub fn stop(&self) -> f64 { + self.bounds().map_or(0.0, |b| b.x1.max(0.0) / 2.0) + } +} + /// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum Arrowhead { Triangle, TriangleOpen, @@ -102,6 +170,8 @@ pub enum Arrowhead { /// more) meeting at right angles in a sharp tip, cut square at their ends. Twice as wide as /// the other heads. LineArrow, + /// A head the user drew, with its art. + Custom(Arc), } impl Arrowhead { @@ -151,23 +221,43 @@ impl Arrowhead { ]; /// How far any part of the head can be from its tip, in units of its weight. - pub fn reach(self) -> f64 { + pub fn reach(&self) -> f64 { match self { // Its farthest corners are 4 + ½√2 back and 4 − ½√2 out. Arrowhead::LineArrow => 5.75, + Arrowhead::Custom(c) => c.reach(), _ => HEAD_REACH, } } /// How far the head spreads either side of its line, in units of its weight. - pub fn half_width(self) -> f64 { + pub fn half_width(&self) -> f64 { match self { Arrowhead::LineArrow => 4.0, + Arrowhead::Custom(c) => c.half_width(), _ => 2.0, } } + /// How far past its end point a head placed with [`ArrowAlign::Extend`] can paint, in units of + /// its weight: the built-in heads' length, or a custom head's reach past its tip, which sits + /// [`CustomArrowhead::stop`] past the end point. + pub fn length(&self) -> f64 { + match self { + Arrowhead::Custom(c) => c.stop() + c.reach(), + _ => 4.0, + } + } - /// The name the arrowhead menus show. - pub fn label(self) -> &'static str { + /// The name the arrowhead menus show: [`Self::label`], or a custom head's own name. + pub fn name(&self) -> &str { + match self { + Arrowhead::Custom(c) => &c.name, + _ => self.label(), + } + } + + /// The name the arrowhead menus show for a built-in head ("Custom" for a custom one, whose + /// own name is [`Self::name`]). + pub fn label(&self) -> &'static str { match self { Arrowhead::Triangle => "Triangle", Arrowhead::TriangleOpen => "Triangle (open)", @@ -210,6 +300,7 @@ impl Arrowhead { Arrowhead::TriangleBar => "Triangle to Bar", Arrowhead::Oval => "Oval", Arrowhead::LineArrow => "Line Arrow", + Arrowhead::Custom(_) => "Custom", } } } diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index 0a1b3e1a5d..66f7fa2462 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -264,13 +264,58 @@ fn arrowhead_names_are_stable_and_every_one_is_listed_once() { ]; for name in SAVED { let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap(); - assert_eq!(serde_json::to_value(a).unwrap(), name); + assert_eq!(serde_json::to_value(&a).unwrap(), name); assert_eq!(Arrowhead::ALL.iter().filter(|x| **x == a).count(), 1, "{name} in the menu once"); } let labels: std::collections::HashSet<&str> = Arrowhead::ALL.iter().map(|a| a.label()).collect(); assert_eq!(labels.len(), Arrowhead::ALL.len(), "labels are distinct"); } +/// A custom head: a triangle from its tip back 3 units, 2 units right and 1 left of its line. +fn custom_head() -> Arrowhead { + use vectorcraft_geom::{PathData, Point, SubPath}; + let path = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(3.0, 2.0), Point::new(3.0, -1.0)], true)); + Arrowhead::Custom(std::sync::Arc::new(CustomArrowhead::new("Wedge".into(), path))) +} + +#[test] +fn custom_arrowheads_carry_their_art_and_measure_it() { + let head = custom_head(); + assert_eq!((head.name(), head.label()), ("Wedge", "Custom")); + assert_eq!(head.reach(), 3.0f64.hypot(2.0)); + assert_eq!(head.half_width(), 2.0); + let Arrowhead::Custom(c) = &head else { panic!("custom") }; + assert_eq!(c.stop(), 1.5, "the line stops halfway along the head"); + // Saved with the stroke, art and all, beside the built-in heads' names. + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); + (st.start_arrow, st.end_arrow) = (Some(Arrowhead::Triangle), Some(head.clone())); + let json = serde_json::to_value(&st).unwrap(); + assert_eq!(json["start_arrow"], "Triangle"); + assert_eq!(json["end_arrow"]["Custom"]["name"], "Wedge"); + let back: StrokeLayer = serde_json::from_value(json).unwrap(); + assert_eq!(back, st); + // Extended, the tip sits the stop past the end point; on it, only the reach counts. + assert_eq!(head.length(), 1.5 + head.reach()); + assert_eq!(st.head_reach(true), head.length() * 2.0 + 1.0); + st.arrow_align = ArrowAlign::Tip; + assert_eq!(st.head_reach(true), head.reach() * 2.0 + 1.0); +} + +#[test] +fn custom_arrowheads_out_of_bounds_draw_nothing() { + let Arrowhead::Custom(c) = custom_head() else { panic!("custom") }; + let mut path = c.path.clone(); + path.subpaths[0].anchors[1] = vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(1e9, 0.0)); + // A file can hold what `new` drops: reading it drops it too. + let json = serde_json::to_value(CustomArrowhead { name: "Wedge".into(), path }).unwrap(); + let far: CustomArrowhead = serde_json::from_value(json).unwrap(); + assert_eq!((far.name.as_str(), far.path.subpaths.len()), ("Wedge", 0)); + assert_eq!((far.reach(), far.half_width(), far.stop()), (0.0, 0.0, 0.0)); + let anchors = vec![vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(1.0, 0.0)); CustomArrowhead::MAX_ANCHORS + 1]; + let many = CustomArrowhead::new("Many".into(), vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::new(anchors, true))); + assert!(many.path.subpaths.is_empty(), "too many anchors"); +} + #[test] fn arrow_align_defaults_to_extend_and_round_trips() { let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 41a684abde..cdad1e7a93 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -82,8 +82,8 @@ pub(crate) mod skip; pub use appearance::StrokeGradientMode; pub use appearance::{ - AngleProfile, Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset, - SavedProfile, StrokeAlign, StrokeLayer, WidthProfile, + AngleProfile, Appearance, AppearanceItem, ArrowAlign, Arrowhead, CustomArrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, + ProfilePreset, SavedProfile, StrokeAlign, StrokeLayer, WidthProfile, }; pub use assets::ExportAsset; pub use compound_shape::ShapeMode; diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index f6b9a4d013..763eea56a3 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -391,13 +391,15 @@ impl StrokeLayer { /// past the end; with [`ArrowAlign::Tip`] its tip is on the end and it lies back along the /// chord the path bends through under it, so anywhere within its length of the end. fn head_contains(&self, at: Point, out: Vec2, end: bool, p: Point, tol: f64) -> bool { - let Some(kind) = (if end { self.end_arrow } else { self.start_arrow }) else { return false }; + let Some(kind) = (if end { &self.end_arrow } else { &self.start_arrow }) else { return false }; let (hw, v) = (self.arrow_weight(end), p - at); match self.arrow_align { ArrowAlign::Tip => v.hypot() <= kind.reach() * hw + tol, ArrowAlign::Extend => { let along = v.dot(out); - v.cross(out).abs() <= kind.half_width() * hw + tol && along <= 4.0 * hw + self.width / 2.0 + tol && along >= -kind.reach() * hw - tol + v.cross(out).abs() <= kind.half_width() * hw + tol + && along <= kind.length() * hw + self.width / 2.0 + tol + && along >= -kind.reach() * hw - tol } } } diff --git a/crates/effects/src/stroke/arrow.rs b/crates/effects/src/stroke/arrow.rs index c96dcb477c..075ab3f7c3 100644 --- a/crates/effects/src/stroke/arrow.rs +++ b/crates/effects/src/stroke/arrow.rs @@ -4,7 +4,8 @@ //! in the back wall of hollow ones (with the cap kept out of the hole) and at the chevron's inner //! corner. With [`ArrowAlign::Tip`] the tip sits on the end point and the stroke is shortened by //! the inset; with [`ArrowAlign::Extend`] the path keeps its length and the tip sits the inset -//! past the end point. Heads added after the first ten are drawn in local units ([`local`]). +//! past the end point. Heads added after the first ten are drawn in local units ([`local`]), as are +//! custom heads, whose stroke ends halfway along their length. use std::borrow::Cow; @@ -37,8 +38,14 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> let last = subs.last().filter(|s| !s.1).map(|s| s.0.clone()); let cap = cap_extent(st.cap, st.width); let (hw0, hw1) = (st.arrow_weight(false), st.arrow_weight(true)); - let start = st.start_arrow.zip(first).map(|(k, r)| (k, r, inset(k, hw0, cap))); - let end = st.end_arrow.zip(last).map(|(k, r)| (k, r, inset(k, hw1, cap))); + let start = st.start_arrow.clone().zip(first).map(|(k, r)| { + let i = inset(&k, hw0, cap); + (k, r, i) + }); + let end = st.end_arrow.clone().zip(last).map(|(k, r)| { + let i = inset(&k, hw1, cap); + (k, r, i) + }); if start.is_none() && end.is_none() { return StrokePieces { line: Cow::Borrowed(bp), heads: vec![] }; } @@ -56,7 +63,8 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> } else { (end_pt + outward * inset, outward) }; - heads.push(Arrow { kind, outline: shape(kind, tip, dir, hw), tip, dir, inset }); + let outline = shape(&kind, tip, dir, hw); + heads.push(Arrow { kind, outline, tip, dir, inset }); }; if let Some((k, r, i)) = start { place(k, r, true, i, hw0); @@ -174,7 +182,7 @@ fn bar(hw: f64) -> f64 { const NOTCH: f64 = 0.7; /// Distance from the tip where the stroke ends, for a stroke whose cap reaches `cap` past its end. -fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 { +fn inset(kind: &Arrowhead, hw: f64, cap: f64) -> f64 { let l = len(hw); // Solid heads: halfway in, far enough that the cap stays behind the tip. let solid = |back: f64| (back / 2.0).max(cap); @@ -187,6 +195,7 @@ fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 { Arrowhead::TriangleOpen | Arrowhead::CircleOpen | Arrowhead::SquareOpen => hollow(l), // The inner corner of the chevron: arm / sin(half angle). Arrowhead::ArrowOpen => (arm(hw) * (l * l + l * l / 4.0).sqrt() / (l / 2.0)).max(cap), + Arrowhead::Custom(c) => (c.stop() * hw).max(cap), _ => match local_stop(kind) { Stop::At(u) => (u * hw).max(cap), Stop::Wall(back) => hollow(back * hw), @@ -195,7 +204,7 @@ fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 { } /// Outline of a `kind` head of weight `hw` with its tip at `tip`, pointing along `dir`. -fn shape(kind: Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath { +fn shape(kind: &Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath { let n = Vec2::new(-dir.y, dir.x); let l = len(hw); let half = l / 2.0; @@ -253,7 +262,7 @@ const HEX: f64 = 2.0 * 1.732_050_807_568_877_2; const LOCAL_TOL: f64 = 1e-3; /// Where the stroke ends under a head drawn in local units ([`local`]), in those units. -fn local_stop(kind: Arrowhead) -> Stop { +fn local_stop(kind: &Arrowhead) -> Stop { match kind { Arrowhead::Target | Arrowhead::TagOpen | Arrowhead::DiamondOpen => Stop::Wall(4.0), Arrowhead::HexagonOpen => Stop::Wall(HEX), @@ -276,8 +285,8 @@ fn local_stop(kind: Arrowhead) -> Stop { /// The outline of a head added after the first ten, in local units: x from the tip (0) back /// along the line (to at most 4; the line arrow's square-cut arms 4.71), y across it (−2 to 2, /// the line arrow −4 to 4; positive to the right of the direction the head points). Every shape -/// is generated here. -fn local(kind: Arrowhead) -> BezPath { +/// is generated here, and a custom head's outline is its own. +fn local(kind: &Arrowhead) -> BezPath { let mut p = BezPath::new(); let poly = |p: &mut BezPath, pts: &[(f64, f64)]| polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::>()); let ring_poly = |p: &mut BezPath, pts: &[(f64, f64)]| ring_polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::>(), ring(1.0)); @@ -373,6 +382,7 @@ fn local(kind: Arrowhead) -> BezPath { let t = FRAC_1_SQRT_2; poly(&mut p, &[(0.0, 0.0), (4.0, 4.0), (4.0 + t, 4.0 - t), (2.0 * t, 0.0), (4.0 + t, t - 4.0), (4.0, -4.0)]); } + Arrowhead::Custom(c) => p = c.path.to_bezpath(), // The first ten are drawn by `shape`. Arrowhead::Triangle | Arrowhead::TriangleOpen diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index 46969ff639..aa4ea8dc9f 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -282,7 +282,7 @@ fn no_heads_borrows_the_path_and_closed_paths_get_none() { #[test] fn tip_mode_shortens_the_stroke_by_the_head_and_puts_the_tip_on_the_end_point() { for kind in Arrowhead::ALL { - let st = arrows(kind, ArrowAlign::Tip, LineCap::Butt); + let st = arrows(kind.clone(), ArrowAlign::Tip, LineCap::Butt); let l = line(0.0, 100.0); let p = stroke_pieces(&l, &st); let [h] = &p.heads[..] else { panic!("one head") }; @@ -337,7 +337,7 @@ fn extend_mode_keeps_the_path_and_overhangs_by_the_inset() { #[test] fn hollow_heads_are_rings_and_the_stroke_stays_out_of_the_hole() { for (kind, cap) in [(Arrowhead::CircleOpen, LineCap::Round), (Arrowhead::SquareOpen, LineCap::Square), (Arrowhead::TriangleOpen, LineCap::Butt)] { - let st = arrows(kind, ArrowAlign::Tip, cap); + let st = arrows(kind.clone(), ArrowAlign::Tip, cap); let l = line(0.0, 100.0); let p = stroke_pieces(&l, &st); let h = &p.heads[0]; @@ -363,7 +363,7 @@ fn hollow_heads_are_rings_and_the_stroke_stays_out_of_the_hole() { fn round_caps_never_poke_past_the_tip() { for kind in Arrowhead::ALL { for scale in [10.0, 100.0, 300.0] { - let mut st = arrows(kind, ArrowAlign::Tip, LineCap::Round); + let mut st = arrows(kind.clone(), ArrowAlign::Tip, LineCap::Round); st.arrow_scale = (scale, scale); let l = line(0.0, 100.0); let p = stroke_pieces(&l, &st); @@ -405,8 +405,8 @@ fn degenerate_paths_and_patterns_neither_panic_nor_hang() { for pat in patterns { for off in [0.0, -7.0, 1e12, f64::INFINITY] { let st = stroke(2.0, |s| { - s.start_arrow = Some(kind); - s.end_arrow = Some(kind); + s.start_arrow = Some(kind.clone()); + s.end_arrow = Some(kind.clone()); s.arrow_align = align; s.cap = cap; s.dash = Some(dashed(pat, off)); diff --git a/crates/effects/src/stroke/tests_arrows.rs b/crates/effects/src/stroke/tests_arrows.rs index 4328048991..a32a70245b 100644 --- a/crates/effects/src/stroke/tests_arrows.rs +++ b/crates/effects/src/stroke/tests_arrows.rs @@ -50,7 +50,7 @@ fn every_outline_is_drawn_inside_its_box_without_self_intersections() { let (o, d) = (Point::ORIGIN, Point::new(2.0, 2.0)); assert!(cross((o, d), (Point::new(0.0, 2.0), Point::new(2.0, 0.0))) && !cross((o, d), (d, Point::new(4.0, 0.0))), "the check itself"); for kind in Arrowhead::ALL { - let h = head(kind); + let h = head(kind.clone()); assert!(h.outline.area().abs() > 4.0, "{kind:?} is drawn"); let b = h.outline.bounding_box(); let (reach, side) = (2.0 * kind.reach(), 2.0 * kind.half_width()); @@ -73,7 +73,7 @@ fn every_outline_is_drawn_inside_its_box_without_self_intersections() { #[test] fn the_stroke_ends_inside_every_head() { for kind in Arrowhead::ALL { - let h = head(kind); + let h = head(kind.clone()); // Just in front of where the line stops, on its centre line. let end = h.tip - h.dir * (h.inset - 0.1); assert_ne!(h.outline.winding(end), 0, "{kind:?}: the line ends outside the head (inset {})", h.inset); @@ -112,3 +112,30 @@ fn half_arrows_barb_on_their_own_side() { assert!(head(Arrowhead::HalfArrowLeft).outline.bounding_box().y1 < 1.5); assert!(head(Arrowhead::HalfArrowRight).outline.bounding_box().y1 > 3.0); } + +#[test] +fn custom_heads_draw_their_own_art_at_the_tip() { + use vectorcraft_doc::CustomArrowhead; + use vectorcraft_geom::{PathData, SubPath}; + // From the tip back 3 units, 2 to the right of the line and 1 to the left. + let wedge = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(3.0, 2.0), Point::new(3.0, -1.0)], true)); + let kind = Arrowhead::Custom(std::sync::Arc::new(CustomArrowhead::new("Wedge".into(), wedge))); + let h = head(kind.clone()); + // Weight 2: drawn back to x 94, right of +x is +y on a y-down page. + let b = h.outline.bounding_box(); + assert!((b.x0 - 94.0).abs() < 1e-9 && (b.x1 - 100.0).abs() < 1e-9 && (b.y0 + 2.0).abs() < 1e-9 && (b.y1 - 4.0).abs() < 1e-9, "{b:?}"); + assert_eq!(h.outline.winding(Point::new(95.0, 1.0)), h.outline.winding(Point::new(95.0, 0.5))); + assert_ne!(h.outline.winding(Point::new(95.0, 1.0)), 0); + // The line stops halfway along the head. + assert!((h.inset - 3.0).abs() < 1e-9, "inset {}", h.inset); + // Extended, the tip sits the inset past the end point, and scaling scales the art. + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); + (st.start_arrow, st.arrow_scale) = (Some(kind), (200.0, 100.0)); + let mut l = BezPath::new(); + l.move_to((0.0, 0.0)); + l.line_to((100.0, 0.0)); + let p = stroke_pieces(&l, &st); + let [s] = &p.heads[..] else { panic!("one head") }; + assert!((s.tip.x + 6.0).abs() < 1e-9 && s.dir.x < 0.0, "tip {:?}", s.tip); + assert!((s.outline.bounding_box().width() - 12.0).abs() < 1e-9); +} diff --git a/crates/effects/src/stroke/tests_reach.rs b/crates/effects/src/stroke/tests_reach.rs index 29a48343ed..6e141fd5b5 100644 --- a/crates/effects/src/stroke/tests_reach.rs +++ b/crates/effects/src/stroke/tests_reach.rs @@ -99,14 +99,27 @@ fn bounds_and_clicks_cover_what_every_stroke_paints() { } } +/// Custom heads: one wholly behind its tip, and a long one reaching as far in front of its tip as +/// behind it (its tip sits halfway along it past the end point with Extend). +fn custom_heads() -> Vec { + use vectorcraft_geom::{Point as P, SubPath}; + let head = |name: &str, pts: &[(f64, f64)]| { + let pts: Vec

= pts.iter().map(|&(x, y)| P::new(x, y)).collect(); + let path = PathData::single(SubPath::polyline(&pts, true)); + Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new(name.into(), path))) + }; + vec![head("Wedge", &[(0.0, 0.0), (3.0, 2.0), (3.0, -1.0)]), head("Spindle", &[(-10.0, 0.0), (0.0, 2.0), (10.0, 0.0), (0.0, -2.0)])] +} + #[test] fn bounds_and_clicks_cover_every_arrowhead() { let bp = &paths()[0]; - for kind in Arrowhead::ALL { + for kind in Arrowhead::ALL.into_iter().chain(custom_heads()) { for align in [ArrowAlign::Extend, ArrowAlign::Tip] { for (scale, cap) in [(50.0, LineCap::Round), (100.0, LineCap::Butt), (300.0, LineCap::Square)] { let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 3.0); - (st.start_arrow, st.end_arrow, st.arrow_align, st.arrow_scale, st.cap) = (Some(kind), Some(kind), align, (scale, scale), cap); + (st.start_arrow, st.end_arrow, st.arrow_align, st.arrow_scale, st.cap) = + (Some(kind.clone()), Some(kind.clone()), align, (scale, scale), cap); check(bp, &st, &format!("{kind:?} {align:?} {scale}%")); } } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 72e33b8993..12e39d1c3c 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -4,6 +4,7 @@ pub mod actionset; mod addons; mod apiref; pub(crate) mod appearance; +pub mod arrowheads; mod assets; mod attributes; pub mod brushlib; @@ -297,6 +298,7 @@ pub fn command_specs() -> &'static [CommandSpec] { // Split out of `paint` (M3.8); kept next to it so lists in registry order (the command // palette, `engine.commands`) show them where they always were. v.extend(stroke::specs()); + v.extend(arrowheads::specs()); v.extend(appearance::specs()); v.extend(style::specs()); v.extend(swatch::specs()); diff --git a/crates/engine/src/cmd/arrowheads.rs b/crates/engine/src/cmd/arrowheads.rs new file mode 100644 index 0000000000..8e2eee865f --- /dev/null +++ b/crates/engine/src/cmd/arrowheads.rs @@ -0,0 +1,423 @@ +//! Custom arrowheads (Stroke panel menu › Edit Arrowheads…). The arrowheads file is a native +//! document kept next to the preferences ([`Session::attach_arrowheads_file`]). Each of its symbols +//! is one arrowhead, listed by the symbol's name after the built-in heads in the Stroke panel's +//! arrowhead menus. A head points right (+x): the symbol's registration point is its tip, and the +//! art's height (its extent across the line) is drawn as wide as the built-in heads, four times +//! the head's weight. A head covers what its art paints: the visible fills, and the visible +//! strokes outlined, of the paths, compound paths and groups in it, and its gradient meshes, with +//! their geometry effects applied and clip groups clipped. A head is one solid shape in the stroke's paint: art at 0 % +//! opacity is left out, and other opacities, blend modes and opacity masks do not change the +//! shape. Saving the file reloads the heads. A stroke that uses a custom head carries a copy of its art +//! ([`vectorcraft_doc::CustomArrowhead`]), so its document draws it anywhere. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, CustomArrowhead, Document, Node, NodeId, NodeKind, Symbol, SymbolRegistration}; +use vectorcraft_geom::{Affine, FillRule, PathData, Point, SubPath}; +use vectorcraft_pathops::BoolOp; + +use super::{CommandSpec, Result, Session, always, bad, cmd}; + +/// The arrowheads file's name, in the folder of the preferences. +pub const FILE_NAME: &str = "Arrowheads.vectorcraft"; +/// The largest arrowheads file read. +pub const MAX_FILE_BYTES: u64 = 64 << 20; +/// The most heads read from the file; symbols past it are skipped. +pub const MAX_HEADS: usize = 256; +/// The most anchors a symbol's art, and the shapes it paints, may have; a symbol past it is skipped. +pub const MAX_ART_ANCHORS: usize = 16 * CustomArrowhead::MAX_ANCHORS; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "stroke.arrowheads.list", + "Arrowheads", + [], + None, + "{} → {arrowheads: [{name (stroke.set `startArrow`/`endArrow` as {custom: name}), reach, halfWidth (in head weights)}], file: the arrowheads file's path or null (no file here, as on the web), skipped: [{name, reason}] symbols of the file that are not heads, error: why the file could not be read, or null} the custom arrowheads, listed after the built-in ones in the Stroke panel's arrowhead menus", + always, + list + ), + cmd!( + "stroke.arrowheads.edit", + "Edit Arrowheads", + [], + None, + "{} open the arrowheads file (a native document next to the preferences; created first with one example head when it is missing). Each symbol in it is one custom arrowhead named after the symbol: it points right, its registration point is the tip, and its height is drawn as wide as the built-in heads; its visible fills and outlined strokes make the head. Saving the file reloads the heads → {path, created: whether the file was made now}", + has_file, + edit + ), + cmd!( + "stroke.arrowheads.reload", + "Reload Arrowheads", + [], + None, + "{} read the arrowheads file again (a save of it from VectorCraft does this already) → {count, skipped: [{name, reason}]}", + has_file, + reload + ), + ] +} + +/// The custom arrowheads read from the arrowheads file. +#[derive(Default)] +pub struct Arrowheads { + path: Option, + heads: Vec>, + skipped: Vec<(String, String)>, + error: Option, +} + +impl Arrowheads { + /// The arrowheads file's path; `None` without one (the web, runs without preferences). + pub fn path(&self) -> Option<&str> { + self.path.as_deref() + } + /// The heads, in the order of the file's symbols. + pub fn heads(&self) -> &[Arc] { + &self.heads + } + /// Head `name`. + pub fn get(&self, name: &str) -> Option> { + self.heads.iter().find(|h| h.name == name).cloned() + } + fn set(&mut self, read: Read) { + (self.heads, self.skipped) = (read.heads.into_iter().map(Arc::new).collect(), read.skipped); + self.error = None; + } +} + +/// The heads a document holds, and the symbols that are not heads with the reason. +#[derive(Debug, Default)] +pub struct Read { + pub heads: Vec, + pub skipped: Vec<(String, String)>, +} + +/// The heads of document `doc`: one per symbol, up to [`MAX_HEADS`] (see the module docs). +pub fn heads_of(doc: &Document) -> Read { + let mut d = doc.clone(); + let brushes = vectorcraft_brush::library(&d); + let mut out = Read::default(); + for (i, sym) in doc.symbols.iter().enumerate() { + if i >= MAX_HEADS { + out.skipped.push((sym.name.clone(), format!("only the first {MAX_HEADS} symbols are read"))); + continue; + } + if out.heads.iter().any(|h| h.name == sym.name) { + out.skipped.push((sym.name.clone(), "another symbol has this name".into())); + continue; + } + match head(&mut d, &brushes, sym) { + Ok(h) => out.heads.push(h), + Err(why) => out.skipped.push((sym.name.clone(), why.into())), + } + } + out +} + +/// Symbol `sym` of `d` as a head. +fn head(d: &mut Document, brushes: &[vectorcraft_brush::Brush], sym: &Symbol) -> std::result::Result { + const TOO_DETAILED: &str = "its art is too long or too detailed for a head"; + if art_anchors(&sym.art) > MAX_ART_ANCHORS { + return Err(TOO_DETAILED); + } + let art = baked_art(d, sym); + let mut shapes = Shapes::default(); + painted(d, brushes, &art, &mut shapes); + if shapes.too_detailed { + return Err(TOO_DETAILED); + } + let area = shapes.united(); + let b = area.bounds().filter(|b| b.height() > 1e-9 && b.is_finite()).ok_or("its art paints nothing")?; + let tip = b.center() + d.symbol_registration(&sym.name).offset_from_center(b.width(), b.height()); + // Head units: x back from the tip, y across to the right of +x, 4 units to the art's height. + let k = 4.0 / b.height(); + let local = Affine::new([-k, 0.0, 0.0, k, k * tip.x, -k * tip.y]); + let h = CustomArrowhead::new(sym.name.clone(), area.transformed(local)); + if h.path.subpaths.is_empty() { + return Err(TOO_DETAILED); + } + Ok(h) +} + +/// The anchors of the paths in `n` and of the pieces of its gradient meshes ([`mesh_anchors`]), its +/// members' included. +fn art_anchors(n: &Node) -> usize { + let own = match &n.kind { + NodeKind::Mesh(m) => mesh_anchors(m), + _ => n.path_data().map_or(0, PathData::anchor_count), + }; + n.children().map_or(own, |c| c.iter().fold(own, |sum, c| sum.saturating_add(art_anchors(c)))) +} + +/// The art of symbol `sym` of `d` with every geometry effect in it applied (its objects', fills', +/// strokes' and groups'), as the canvas draws it: the art baked as for export, on a copy of `d`. +fn baked_art(d: &Document, sym: &Symbol) -> Node { + // A plain new layer, so the file's own layers' effects stay out of the art. + let mut tmp = d.clone(); + tmp.layers = Document::new(1.0, 1.0).layers; + let Some(children) = tmp.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) else { return (*sym.art).clone() }; + *children = vec![sym.art.clone()]; + let baked = vectorcraft_render::effects::bake_document(&tmp); + baked.as_ref().and_then(|b| b.layers.first()?.children()?.first().map(|c| (**c).clone())).unwrap_or_else(|| (*sym.art).clone()) +} + +/// The shapes a symbol's art paints, up to [`MAX_ART_ANCHORS`] anchors in all. +#[derive(Default)] +struct Shapes { + list: Vec<(PathData, FillRule)>, + anchors: usize, + /// More anchors were asked for than fit; the shapes past the limit were not made. + too_detailed: bool, +} + +impl Shapes { + /// Whether `anchors` more fit; when they do not, the shapes are too detailed. + fn room(&mut self, anchors: usize) -> bool { + self.too_detailed |= self.anchors.saturating_add(anchors) > MAX_ART_ANCHORS; + !self.too_detailed + } + fn push(&mut self, shape: (PathData, FillRule)) { + if self.room(shape.0.anchor_count()) { + self.anchors += shape.0.anchor_count(); + self.list.push(shape); + } + } + fn united(&self) -> PathData { + let refs: Vec<(&PathData, FillRule)> = self.list.iter().map(|(p, r)| (p, *r)).collect(); + vectorcraft_pathops::unite_all(&refs) + } + /// The shapes of `inside` clipped to `outline` (filled under `rule`), added as one. + fn push_clipped(&mut self, inside: Shapes, outline: &PathData, rule: FillRule) { + if inside.too_detailed || !self.room(inside.anchors) { + self.too_detailed = true; + return; + } + let clipped = vectorcraft_pathops::boolean(&inside.united(), FillRule::NonZero, outline, rule, BoolOp::Intersect); + if !clipped.subpaths.is_empty() { + self.push((clipped, FillRule::NonZero)); + } + } +} + +/// The shapes the shown paths and compound paths in `n` (baked art) paint: their filled areas and +/// their strokes' outlines, its groups' members included (baking has already made a group's own +/// fills and strokes art among its members), and the areas its gradient meshes cover. A clip +/// group's clipped art is clipped to +/// its clipping path, whose own fill and stroke paint unclipped, as on the canvas. Hidden or fully +/// transparent art is left out. +fn painted(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node, out: &mut Shapes) { + if !n.visible || n.opacity <= 0.0 || out.too_detailed { + return; + } + match &n.kind { + NodeKind::Path { guide: true, .. } => {} + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + fills(n, out); + // The outline holds a copy of the path for each fill too, which adds nothing new. + if let Some(o) = super::pathops::outline_strokes(d, brushes, n) { + fills_in(&o, out); + } + } + NodeKind::Group { children, clip: true } => { + let Some((clip, rest)) = children.split_first() else { return }; + painted(d, brushes, clip, out); + // Nothing to clip by hides the clipped art, as on the canvas. + let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return }; + let mut inside = Shapes { anchors: out.anchors, ..Default::default() }; + rest.iter().for_each(|c| painted(d, brushes, c, &mut inside)); + inside.anchors -= out.anchors; + out.push_clipped(inside, &PathData::from_bezpath(&outline), rule); + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| painted(d, brushes, c, out)), + NodeKind::Mesh(_) => mesh_area(n, out), + _ => {} + } +} + +/// The areas the shown paths and gradient meshes in `n` (an outlined stroke) fill, clipped by its +/// clip groups, whose clipping paths' own fills paint unclipped. A stroke with a gradient along or +/// across it is outlined as gradient meshes clipped to the stroke's outline. +fn fills_in(n: &Node, out: &mut Shapes) { + if !n.visible || n.opacity <= 0.0 || out.too_detailed { + return; + } + match &n.kind { + NodeKind::Mesh(_) => mesh_area(n, out), + NodeKind::Group { children, clip: true } => { + let Some((clip, rest)) = children.split_first() else { return }; + fills_in(clip, out); + let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return }; + let mut inside = Shapes { anchors: out.anchors, ..Default::default() }; + rest.iter().for_each(|c| fills_in(c, &mut inside)); + inside.anchors -= out.anchors; + out.push_clipped(inside, &PathData::from_bezpath(&outline), rule); + } + _ => match n.children() { + Some(children) => children.iter().for_each(|c| fills_in(c, out)), + None => fills(n, out), + }, + } +} + +/// The anchors [`mesh_area`] makes at most of mesh `m`: its outline's, one a patch along each side, +/// when no point of it is fully transparent, else four by four quads of four anchors a patch. +fn mesh_anchors(m: &vectorcraft_doc::GradientMesh) -> usize { + let (rows, cols) = (m.rows as usize, m.cols as usize); + if opaque(m) { rows.saturating_add(cols).saturating_mul(2) } else { rows.saturating_mul(cols).saturating_mul(64) } +} + +fn opaque(m: &vectorcraft_doc::GradientMesh) -> bool { + m.points.iter().all(|p| p.opacity > 0.0) +} + +/// The area gradient mesh `n` paints: its outline when no point of it is fully transparent, else +/// the pieces of it that are not fully transparent (four by four quads a patch). A mesh whose +/// pieces do not fit in `out` makes the shapes too detailed. +fn mesh_area(n: &Node, out: &mut Shapes) { + let NodeKind::Mesh(m) = &n.kind else { return }; + if !out.room(mesh_anchors(m)) { + return; + } + if opaque(m) { + let outline = m.outline(); + if !outline.subpaths.is_empty() { + out.push((outline, FillRule::NonZero)); + } + return; + } + for q in m.quads(4) { + if q.opacity > 0.0 || q.corners.iter().any(|(_, o)| *o > 0.0) { + out.push((PathData::single(SubPath::polyline(&q.pts, true)), FillRule::NonZero)); + } + } +} + +/// The area path `n` fills, when a shown fill paints it. +fn fills(n: &Node, out: &mut Shapes) { + let paints = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && f.opacity > 0.0 && !f.paint.is_none()); + if n.appearance.items.iter().any(paints) + && let Some(shape) = super::pathops::node_path(n) + { + out.push(shape); + } +} + +/// The document a missing arrowheads file starts as: one example head, a solid triangle. +pub fn starter() -> Document { + let mut doc = Document::new(612.0, 792.0); + doc.title = "Arrowheads".into(); + let name = "Example Arrowhead"; + let tri = SubPath::polyline(&[Point::new(-10.0, -8.0), Point::new(10.0, 0.0), Point::new(-10.0, 8.0)], true); + let art = Node::path(NodeId(0), PathData::single(tri), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0)); + let art = doc.reid(&art); + doc.symbols.push(Symbol { name: name.into(), art: Arc::new(art), dynamic: false }); + // The tip is the right edge's middle. + doc.set_symbol_registration(name, SymbolRegistration(5)); + let id = doc.alloc_id(); + let instance = Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::translate((306.0, 396.0)) }); + let layer = doc.layers.first().map(|l| l.id); + let _ = doc.insert(layer, 0, instance); + doc +} + +impl Session { + /// Keep the custom arrowheads in the file at `path` and read the heads it holds (none while it + /// is missing; an unreadable file is reported by `stroke.arrowheads.list`). + pub fn attach_arrowheads_file(&mut self, path: String) { + self.arrowheads.path = Some(path); + self.reload_arrowheads(); + } + + /// Read the arrowheads file again. + fn reload_arrowheads(&mut self) { + let Some(path) = self.arrowheads.path.clone() else { return }; + match read(&path) { + Ok(Some(doc)) => self.arrowheads.set(heads_of(&doc)), + Ok(None) => self.arrowheads.set(Read::default()), + Err(e) => { + self.arrowheads.set(Read::default()); + self.arrowheads.error = Some(e); + } + } + } + + /// Document `doc` was saved to `path`: when that is the arrowheads file, its heads are the + /// ones listed now. + pub(crate) fn arrowheads_saved(&mut self, path: &str, doc: &Document) { + if self.arrowheads.path.as_deref().is_some_and(|a| same_file(a, path)) { + self.arrowheads.set(heads_of(doc)); + } + } +} + +fn same_file(a: &str, b: &str) -> bool { + let canon = |p: &str| std::fs::canonicalize(p).unwrap_or_else(|_| std::path::PathBuf::from(p)); + std::path::Path::new(a) == std::path::Path::new(b) || canon(a) == canon(b) +} + +/// The document in the arrowheads file at `path`; `None` when there is no file. +fn read(path: &str) -> std::result::Result, String> { + let size = match std::fs::metadata(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("{path}: {e}")), + Ok(m) if !m.is_file() => return Err(format!("{path}: not a file")), + Ok(m) => m.len(), + }; + if size > MAX_FILE_BYTES { + return Err(format!("{path}: larger than {} MB", MAX_FILE_BYTES >> 20)); + } + let bytes = crate::file_access::unconfined(|| super::fileio::read_file(path)).map_err(|e| e.to_string())?; + let parse = || super::fileio::native_file(&bytes).map(|(doc, ..)| doc).map_err(|e| e.to_string()); + crate::guard::catch_panic(parse).map_err(|_| format!("{path}: could not be read"))?.map(Some) +} + +fn has_file(s: &Session) -> std::result::Result<(), String> { + if s.arrowheads.path.is_some() { Ok(()) } else { Err("no arrowheads file here".into()) } +} + +fn skipped(s: &Session) -> Vec { + s.arrowheads.skipped.iter().map(|(name, reason)| json!({"name": name, "reason": reason})).collect() +} + +fn list(s: &mut Session, _: &Value) -> Result { + let heads: Vec = s.arrowheads.heads.iter().map(|h| json!({"name": h.name, "reach": h.reach(), "halfWidth": h.half_width()})).collect(); + Ok(json!({"arrowheads": heads, "file": s.arrowheads.path, "skipped": skipped(s), "error": s.arrowheads.error})) +} + +fn reload(s: &mut Session, _: &Value) -> Result { + const C: &str = "stroke.arrowheads.reload"; + s.reload_arrowheads(); + if let Some(e) = &s.arrowheads.error { + return Err(bad(C, e.clone())); + } + Ok(json!({"count": s.arrowheads.heads.len(), "skipped": skipped(s)})) +} + +fn edit(s: &mut Session, _: &Value) -> Result { + const C: &str = "stroke.arrowheads.edit"; + let path = s.arrowheads.path.clone().ok_or_else(|| bad(C, "no arrowheads file here"))?; + let created = !std::path::Path::new(&path).exists(); + if created { + if let Some(dir) = std::path::Path::new(&path).parent() { + std::fs::create_dir_all(dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?; + } + let bytes = vectorcraft_format::save_file(&starter()); + crate::file_access::unconfined(|| super::fileio::write_file(&path, &bytes)).map_err(|e| bad(C, e.to_string()))?; + s.reload_arrowheads(); + } + // Already open: that document comes to the front. + let open = s.documents().iter().position(|st| st.path.as_deref().is_some_and(|p| same_file(p, &path))); + match open { + Some(i) => { + s.set_active(i); + } + None => { + crate::file_access::unconfined(|| s.execute("document.open", &json!({ "path": path })))?; + } + } + Ok(json!({"path": path, "created": created})) +} diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs index e1b88d5d9f..44ac08d7ad 100644 --- a/crates/engine/src/cmd/dimension.rs +++ b/crates/engine/src/cmd/dimension.rs @@ -568,7 +568,7 @@ fn appearance(o: &DimensionOptions, mode: ColorMode, ext: bool, arrows: bool) -> ArrowStyle::Tick => Some(Arrowhead::Slash), ArrowStyle::None => None, }; - st.start_arrow = head; + st.start_arrow = head.clone(); st.end_arrow = head; st.arrow_scale = (o.arrow_scale, o.arrow_scale); st.arrow_align = vectorcraft_doc::appearance::ArrowAlign::Tip; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 9f4e5b0572..6e814569a0 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -1005,6 +1005,8 @@ impl SaveJob { } // The other open documents that place this one show the new version. let written = self.plan.path.clone().filter(|_| matches!(self.plan.format.id, "vectorcraft" | "template")); + // A save of the arrowheads file lists its heads in the arrowhead menus. + let native = (self.plan.format.id == "vectorcraft").then(|| self.snapshot.clone()); if let Some(st) = s.document_mut(uid) { self.finish(st); } @@ -1012,6 +1014,9 @@ impl SaveJob { crate::cmd::recovery::forget(s, uid); } if let Some(path) = written { + if let Some(doc) = native { + s.arrowheads_saved(&path, &doc); + } crate::cmd::links::refresh_placed(s, uid, &path); } } diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 541389bb89..ce48ccd933 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -675,5 +675,6 @@ pub(super) const DENIED: &[&str] = &[ "object.blend.options", "object.liquify", "object.pathfinder.options", + "stroke.arrowheads.edit", "type.compositeFonts.set", ]; diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index e97659bfc9..49b3128f61 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -4,8 +4,10 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ - ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer, Unit, WidthProfile, + ArrowAlign, Arrowhead, CharStyle, CustomArrowhead, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer, + Unit, WidthProfile, }; +use vectorcraft_geom::PathData; use super::appearance::{ItemTarget, edit_items, item_target}; use super::paint::painted; @@ -20,7 +22,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", has_doc, stroke_set ), @@ -222,6 +224,23 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { None => Ok(None), Some(Value::Null) => Ok(Some(None)), Some(Value::String(n)) if n == "none" => Ok(Some(None)), + // A custom head by name, from the arrowheads file (stroke.arrowheads.list). + Some(Value::Object(o)) if o.len() == 1 && o.contains_key("custom") => { + let Some(Value::String(name)) = o.get("custom") else { return Err(bad(C, format!("{k}: custom must be a name"))) }; + let head = s.arrowheads.get(name).ok_or_else(|| bad(C, format!("no custom arrowhead {name} (see stroke.arrowheads.list)")))?; + Ok(Some(Some(Arrowhead::Custom(head)))) + } + // A custom head with its art: an outline the head would drop is refused, not drawn empty. + Some(Value::Object(o)) if o.contains_key("Custom") => { + let raw = o.get("Custom").and_then(|c| c.get("path")).cloned().unwrap_or(Value::Null); + let path: PathData = serde_json::from_value(raw).map_err(|e| bad(C, format!("{k}: bad custom arrowhead path: {e}")))?; + if path.subpaths.iter().all(|sp| sp.anchors.is_empty()) || !CustomArrowhead::fits(&path) { + let (most, far) = (CustomArrowhead::MAX_ANCHORS, CustomArrowhead::MAX_UNITS); + return Err(bad(C, format!("{k}: a custom arrowhead's path needs 1 to {most} anchors, each within {far} head units of its tip"))); + } + let head = p.get(k).cloned().unwrap_or(Value::Null); + serde_json::from_value::(head).map(|a| Some(Some(a))).map_err(|e| bad(C, format!("{k}: bad custom arrowhead: {e}"))) + } Some(v) => serde_json::from_value::(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad(C, format!("unknown arrowhead {v}"))), } }; @@ -238,11 +257,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { }; let set = |st: &mut StrokeLayer| { change.apply(st); - if let Some(a) = sa { - st.start_arrow = a; + if let Some(a) = &sa { + st.start_arrow = a.clone(); } - if let Some(a) = ea { - st.end_arrow = a; + if let Some(a) = &ea { + st.end_arrow = a.clone(); } if let Some(a) = arrow_align { st.arrow_align = a; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 091348a6bd..9a1a536d84 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -151,6 +151,8 @@ pub struct Session { pub symbol_libraries: cmd::symbollib::Libraries, /// User Defined and loaded brush libraries (Window → Brush Libraries); not saved. pub brush_libraries: cmd::brushlib::Libraries, + /// The custom arrowheads of the arrowheads file (`stroke.arrowheads.*`); none without one. + pub arrowheads: cmd::arrowheads::Arrowheads, /// 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 diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 63fbcd5a78..a0ca0e3084 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -80,6 +80,7 @@ impl Session { style_libraries: Default::default(), symbol_libraries: Default::default(), brush_libraries: Default::default(), + arrowheads: Default::default(), libraries: Default::default(), pdf_preset_dir: None, dictionary_file: None, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ca0501c8f7..db0a8521a3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9,6 +9,7 @@ pub(super) mod adjust; mod ai_open_path; mod appearance; mod areafit; +mod arrowheads; mod assets; mod assets_3d; mod attributes; diff --git a/crates/engine/src/tests/arrowheads.rs b/crates/engine/src/tests/arrowheads.rs new file mode 100644 index 0000000000..1e5c1fe5a8 --- /dev/null +++ b/crates/engine/src/tests/arrowheads.rs @@ -0,0 +1,252 @@ +//! Custom arrowheads (`cmd/arrowheads.rs`): heads read from the arrowheads file's symbols, listed, +//! set with `stroke.set`, reloaded when the file is saved, and kept with the documents that use +//! them. + +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Arrowhead, Document, Node, StrokeLayer, Symbol, SymbolRegistration}; +use vectorcraft_geom::{PathData, Point, SubPath}; + +use crate::cmd::arrowheads::{self, heads_of}; +use crate::*; + +fn polygon(pts: &[(f64, f64)], ap: Appearance) -> Node { + let pts: Vec = pts.iter().map(|&(x, y)| Point::new(x, y)).collect(); + Node::path(NodeId(0), PathData::single(SubPath::polyline(&pts, true)), ap) +} + +fn add_symbol(d: &mut Document, name: &str, art: Node) { + let art = d.reid(&art); + d.symbols.push(Symbol { name: name.into(), art: Arc::new(art), dynamic: false }); +} + +fn filled() -> Appearance { + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0) +} + +/// A folder of its own for test `name`, emptied. +fn folder(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("vectorcraft-arrowheads-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn session_with_line() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let r = s.execute("shape.line", &json!({"x1": 10, "y1": 100, "x2": 150, "y2": 100})).unwrap(); + (s, NodeId(r["id"].as_u64().unwrap())) +} + +fn stroke(s: &Session, id: NodeId) -> StrokeLayer { + s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone() +} + +#[test] +fn each_symbol_is_a_head_pointing_right_with_its_tip_at_the_registration_point() { + let mut d = Document::new(100.0, 100.0); + // 20 high, so a unit is 5 pt: tip on the right edge's middle. + add_symbol(&mut d, "Wedge", polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled())); + d.set_symbol_registration("Wedge", SymbolRegistration(5)); + // A stroke alone paints its outline: a 10 × 4 band, tip at its centre (the default). + let mut line = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0); + line.stroke_mut().unwrap().cap = vectorcraft_doc::LineCap::Butt; + let band = Node::path(NodeId(0), PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false)), line); + add_symbol(&mut d, "Band", band); + // Neither filled nor stroked: no head. + add_symbol(&mut d, "Ghost", polygon(&[(0.0, 0.0), (5.0, 0.0), (5.0, 5.0)], Appearance::default())); + // Far longer than wide. + add_symbol(&mut d, "Needle", polygon(&[(0.0, 0.0), (1000.0, 0.5), (0.0, 1.0)], filled())); + // A fill at 0 % opacity paints nothing. + let mut clear = filled(); + if let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = clear.items.first_mut() { + f.opacity = 0.0; + } + add_symbol(&mut d, "Clear", polygon(&[(0.0, 0.0), (5.0, 0.0), (5.0, 5.0)], clear)); + // More anchors than a symbol's art may have: skipped before its shapes are united. + let dense: Vec<(f64, f64)> = (0..=arrowheads::MAX_ART_ANCHORS).map(|i| ((i as f64).cos(), (i as f64).sin())).collect(); + add_symbol(&mut d, "Dense", polygon(&dense, filled())); + // A 20 × 4 bar clipped to a 4 × 4 square: a 4 × 4 head. + let mut mask = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut mask.kind { + *clipping = true; + } + let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled()); + add_symbol(&mut d, "Clipped", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(bar)], clip: true })); + // An 8 × 4 bar grown by 2 pt on every side (Offset Path): 12 × 8, so 6 units long. + let mut grown = filled(); + grown.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true }); + add_symbol(&mut d, "Grown", polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], grown)); + // The same effect on the fill alone. + let mut fill_grown = filled(); + fill_grown.items[0].effects_mut().push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true }); + add_symbol(&mut d, "Fill Grown", polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], fill_grown)); + // And on a group holding the bar. + let mut group = Node::group(NodeId(0), vec![Arc::new(polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], filled()))]); + group.appearance.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true }); + add_symbol(&mut d, "Group Grown", group); + // A clipping path stroked 2 pt: its stroke paints unclipped and does not widen the clip. + let mut frame = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0)); + if let NodeKind::Path { clipping, .. } = &mut frame.kind { + *clipping = true; + } + let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled()); + add_symbol(&mut d, "Framed", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(frame), Arc::new(bar)], clip: true })); + // A group's own fill paints its unpainted 8 × 4 member. + let mut painted_group = Node::group(NodeId(0), vec![Arc::new(polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], Appearance::default()))]); + painted_group.appearance = filled().into(); + add_symbol(&mut d, "Group Fill", painted_group); + // A 10 × 4 band stroked with a gradient along it. + let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); + let mut along = Appearance::basic(Paint::None, gradient, 4.0); + if let Some(st) = along.stroke_mut() { + (st.cap, st.gradient_mode) = (vectorcraft_doc::LineCap::Butt, vectorcraft_doc::StrokeGradientMode::Along); + } + let band = Node::path(NodeId(0), PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false)), along); + add_symbol(&mut d, "Gradient Band", band); + // An 8 × 4 mesh of two patches whose right one is fully transparent: a 4 × 4 head. + let corners = vectorcraft_doc::live::grid_points(vectorcraft_geom::Rect::new(0.0, 0.0, 8.0, 4.0), 1, 2); + let points = corners + .iter() + .map(|&p| vectorcraft_doc::MeshPoint { p, color: Color::BLACK, opacity: if p.x > 1.0 { 0.0 } else { 1.0 }, handles: Default::default() }) + .collect(); + let mesh = vectorcraft_doc::GradientMesh { rows: 1, cols: 2, points }; + add_symbol(&mut d, "Half Mesh", Node::new(NodeId(0), NodeKind::Mesh(mesh))); + // An effect on the file's layer is not part of any symbol. + Arc::make_mut(&mut d.layers[0]).appearance.effects.push(vectorcraft_doc::Effect { + id: "path.offsetPath".into(), + params: json!({"offset": 5}), + visible: true, + }); + let read = heads_of(&d); + let names: Vec<&str> = read.heads.iter().map(|h| h.name.as_str()).collect(); + assert_eq!(names, ["Wedge", "Band", "Clipped", "Grown", "Fill Grown", "Group Grown", "Framed", "Group Fill", "Gradient Band", "Half Mesh"]); + let b = read.heads[9].path.bounds().unwrap(); + assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the mesh's painted patch: {b:?}"); + let b = read.heads[7].path.bounds().unwrap(); + assert!((b.width() - 8.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the group's fill: {b:?}"); + let (g, plain) = (read.heads[8].path.bounds().unwrap(), read.heads[1].path.bounds().unwrap()); + let near = |a: f64, b: f64| (a - b).abs() < 1e-3; + assert!(near(g.x0, plain.x0) && near(g.x1, plain.x1) && near(g.y0, plain.y0) && near(g.y1, plain.y1), "the gradient stroke's outline: {g:?}"); + let framed = read.heads[6].path.to_bezpath(); + let b = vectorcraft_geom::Shape::bounding_box(&framed); + assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "6 × 6 with the stroke: {b:?}"); + assert_ne!(vectorcraft_geom::Shape::winding(&framed, Point::new(0.1, 0.1)), 0, "the clipped bar fills the middle"); + for h in &read.heads[3..6] { + let b = h.path.bounds().unwrap(); + assert!((b.width() - 6.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "{}: the effect's outline: {b:?}", h.name); + } + let skipped: Vec<&str> = read.skipped.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(skipped, ["Ghost", "Needle", "Clear", "Dense"], "{:?}", read.skipped); + let b = read.heads[2].path.bounds().unwrap(); + assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "clipped to the square: {b:?}"); + let b = read.heads[0].path.bounds().unwrap(); + assert!(b.x0.abs() < 1e-6 && (b.x1 - 4.0).abs() < 1e-6 && (b.y0 + 2.0).abs() < 1e-6 && (b.y1 - 2.0).abs() < 1e-6, "{b:?}"); + assert!((read.heads[0].reach() - 4.0f64.hypot(2.0)).abs() < 1e-6); + let b = read.heads[1].path.bounds().unwrap(); + assert!((b.x0 + 5.0).abs() < 1e-3 && (b.x1 - 5.0).abs() < 1e-3 && (b.y0 + 2.0).abs() < 1e-3 && (b.y1 - 2.0).abs() < 1e-3, "{b:?}"); +} + +#[test] +fn the_arrowheads_file_is_made_opened_and_reloaded_when_saved() { + let dir = folder("edit"); + let file = dir.join(arrowheads::FILE_NAME).to_string_lossy().to_string(); + let (mut s, id) = session_with_line(); + s.attach_arrowheads_file(file.clone()); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert_eq!((list["arrowheads"].as_array().unwrap().len(), list["file"].as_str()), (0, Some(file.as_str()))); + assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": "Example Arrowhead"}})).is_err(), "no such head yet"); + let line_doc = s.doc().unwrap().uid; + let r = s.execute("stroke.arrowheads.edit", &json!({})).unwrap(); + assert_eq!(r["created"], true); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(file.as_str()), "the file is the active document"); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert_eq!(list["arrowheads"][0]["name"], "Example Arrowhead"); + // A second head drawn in the file is listed once the file is saved. + let st = s.doc_mut().unwrap(); + let d = Arc::make_mut(&mut st.doc); + add_symbol(d, "Square", polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], filled())); + assert_eq!(s.execute("stroke.arrowheads.list", &json!({})).unwrap()["arrowheads"].as_array().unwrap().len(), 1); + s.execute("document.save", &json!({})).unwrap(); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + let names: Vec<&str> = list["arrowheads"].as_array().unwrap().iter().filter_map(|h| h["name"].as_str()).collect(); + assert_eq!(names, ["Example Arrowhead", "Square"]); + // Editing again from the line's document brings the open file to the front, without making + // it anew or opening it twice. + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(s.doc().unwrap().uid, line_doc); + assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], false); + assert_eq!((s.documents().len(), s.doc().unwrap().path.as_deref()), (2, Some(file.as_str()))); + // The line takes the head with its art. + s.execute("document.activate", &json!({"index": 0})).unwrap(); + s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": {"custom": "Square"}})).unwrap(); + let Some(Arrowhead::Custom(c)) = stroke(&s, id).end_arrow else { panic!("a custom head") }; + assert_eq!(c.name, "Square"); + assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": 3}})).is_err()); + assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": "Circle"}})).is_err()); + // A head given with its art is refused when the art could not be drawn. + let mut far = serde_json::to_value(Arrowhead::Custom(c.clone())).unwrap(); + far["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1e6, 1.0)], true))).unwrap(); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": far})).is_err()); + let mut empty = serde_json::to_value(Arrowhead::Custom(c)).unwrap(); + empty["Custom"]["path"] = json!({"subpaths": []}); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": empty})).is_err()); + assert!(matches!(stroke(&s, id).end_arrow, Some(Arrowhead::Custom(h)) if h.name == "Square"), "the head stays as it was"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_document_keeps_its_custom_heads_without_the_arrowheads_file() { + let (mut s, id) = session_with_line(); + let mut d = Document::new(100.0, 100.0); + add_symbol(&mut d, "Wedge", polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled())); + let head = Arrowhead::Custom(Arc::new(heads_of(&d).heads.remove(0))); + // As document.inspect reports it, so it reads back unchanged. + s.execute("stroke.set", &json!({"startArrow": head, "endArrow": "Triangle"})).unwrap(); + assert_eq!(stroke(&s, id).start_arrow, Some(head.clone())); + let inspect = s.execute("document.inspect", &json!({})).unwrap().to_string(); + assert!(inspect.contains("\"Wedge\""), "inspect names the head"); + let ink = |s: &Session| { + vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &Default::default()) + }; + let with_head = ink(&s); + // Saved and opened in a session that has no arrowheads file: the head is drawn the same. + let saved = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone(); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": saved})).unwrap(); + assert_eq!(stroke(&o, id).start_arrow, Some(head)); + assert_eq!(ink(&o).pixels, with_head.pixels); + // SVG and PDF write it as they write the built-in heads. + let svg = |s: &mut Session| s.execute("document.export", &json!({"format": "svg"})).unwrap()["dataBase64"].as_str().unwrap().len(); + let headed = svg(&mut o); + o.execute("stroke.set", &json!({"ids": [id.0], "startArrow": null})).unwrap(); + assert!(headed > svg(&mut o), "the head is in the SVG"); + // Undo puts it back. + o.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(stroke(&o, id).start_arrow, Some(Arrowhead::Custom(_)))); +} + +#[test] +fn an_unreadable_arrowheads_file_lists_no_heads_and_says_why() { + let dir = folder("bad"); + let file = dir.join(arrowheads::FILE_NAME); + std::fs::write(&file, b"not a drawing").unwrap(); + let mut s = Session::new(); + s.attach_arrowheads_file(file.to_string_lossy().to_string()); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert!(list["arrowheads"].as_array().unwrap().is_empty()); + assert!(list["error"].is_string(), "{list}"); + assert!(s.execute("stroke.arrowheads.reload", &json!({})).is_err()); + // A folder in its place is not read either. + std::fs::remove_file(&file).unwrap(); + std::fs::create_dir(&file).unwrap(); + assert!(s.execute("stroke.arrowheads.reload", &json!({})).is_err()); + let _ = std::fs::remove_dir_all(&dir); + // Without a file (the web, runs without preferences) there is nothing to edit. + let mut bare = Session::new(); + assert!(bare.execute("stroke.arrowheads.edit", &json!({})).is_err()); + assert_eq!(bare.execute("stroke.arrowheads.list", &json!({})).unwrap()["file"], serde_json::Value::Null); +} diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 7e7204f6e1..79b5f724a4 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -2109,7 +2109,7 @@ fn empty_artboard_checks_match_the_visual_bounds_on_varied_strokes() { st.cap = [LineCap::Butt, LineCap::Round, LineCap::Square][next(3) as usize]; st.align = [StrokeAlign::Center, StrokeAlign::Inside, StrokeAlign::Outside][next(3) as usize]; st.miter_limit = 1.0 + next(20) as f64; - let mut head = || (next(3) == 0).then(|| Arrowhead::ALL[next(Arrowhead::ALL.len() as u64) as usize]); + let mut head = || (next(3) == 0).then(|| Arrowhead::ALL[next(Arrowhead::ALL.len() as u64) as usize].clone()); (st.start_arrow, st.end_arrow) = (head(), head()); st.arrow_scale = (50.0 + next(250) as f64, 50.0 + next(250) as f64); if next(3) == 0 { diff --git a/crates/engine/src/tests/strokegeom.rs b/crates/engine/src/tests/strokegeom.rs index f7500af358..524dabef3e 100644 --- a/crates/engine/src/tests/strokegeom.rs +++ b/crates/engine/src/tests/strokegeom.rs @@ -51,7 +51,7 @@ fn profiles_come_from_the_preset_catalogue() { assert!(spec.params.contains("arrowAlign")); // …and every arrowhead name `startArrow`/`endArrow` accept. for a in vectorcraft_doc::Arrowhead::ALL { - let name = serde_json::to_value(a).unwrap(); + let name = serde_json::to_value(&a).unwrap(); assert!(spec.params.contains(name.as_str().unwrap()), "{name}"); s.execute("stroke.set", &json!({"endArrow": name})).unwrap(); assert_eq!(stroke(&s, id).end_arrow, Some(a)); diff --git a/crates/pdf/src/tests/stroke.rs b/crates/pdf/src/tests/stroke.rs index 91436a093c..d57cbc42d0 100644 --- a/crates/pdf/src/tests/stroke.rs +++ b/crates/pdf/src/tests/stroke.rs @@ -52,7 +52,7 @@ pub(crate) fn painted_boxes(d: &Document) -> (Vec, Vec) { fn arrowheads_and_the_trimmed_line_match_the_shared_geometry() { for align in [ArrowAlign::Extend, ArrowAlign::Tip] { for kind in Arrowhead::ALL { - let (d, n) = line_doc(arrow_stroke(kind, align, 1.0)); + let (d, n) = line_doc(arrow_stroke(kind.clone(), align, 1.0)); let bp = n.path_data().unwrap().to_bezpath(); let st = n.appearance.stroke().unwrap(); let pieces = stroke_pieces(&bp, st); diff --git a/crates/render/tests/it/golden.rs b/crates/render/tests/it/golden.rs index 4820cf8b60..d6962e0e67 100644 --- a/crates/render/tests/it/golden.rs +++ b/crates/render/tests/it/golden.rs @@ -300,7 +300,7 @@ fn arrowheads_add_ink_at_the_ends() { // Every arrowhead kind renders. for a in Arrowhead::ALL { let mut b = DocBuilder::new(100.0, 100.0); - b.path(line(), stroke_only(2.0, |s| s.end_arrow = Some(a)), |_| {}); + b.path(line(), stroke_only(2.0, |s| s.end_arrow = Some(a.clone())), |_| {}); assert!(render(&b.build()).ink() > plain.ink(), "{a:?}"); } } diff --git a/crates/render/tests/it/stroke_geometry.rs b/crates/render/tests/it/stroke_geometry.rs index a8813da23a..2580fc79c8 100644 --- a/crates/render/tests/it/stroke_geometry.rs +++ b/crates/render/tests/it/stroke_geometry.rs @@ -114,7 +114,7 @@ fn a_hollow_circle_head_is_empty_inside() { fn heads_are_drawn_where_the_shared_geometry_puts_them_and_never_past_the_tip() { for align in [ArrowAlign::Extend, ArrowAlign::Tip] { for kind in Arrowhead::ALL { - let d = arrow_doc(kind, align, 1.0, |s| s.cap = LineCap::Round); + let d = arrow_doc(kind.clone(), align, 1.0, |s| s.cap = LineCap::Round); let h = head_of(&d); if align == ArrowAlign::Tip { assert_eq!(h.tip, Point::new(80.0, 50.0)); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d83b5ac3c6..73d8e836c9 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2759,6 +2759,10 @@ digits Malayalam Малаялам Search Preferences Търсене в настройките No settings found Няма намерени настройки Reset Profiles Нулиране на профилите + Arrowheads Върхове на стрелки + Edit Arrowheads Редактиране на върхове на стрелки + Edit Arrowheads… Редактиране на върхове на стрелки… + Reload Arrowheads Презареждане на върхове на стрелки Reset Rotate View Нулиране на изгледа на завъртане Rotate View to Selection Завъртане на изгледа спрямо селекцията Reset the rows to the artwork's colors Нулиране на редовете към цветовете на илюстрацията diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 663e95ceaa..22faf858cd 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3819,6 +3819,10 @@ theme Appearance Izgled Add to Profiles Dodaj u profile Delete Profile Izbriši profil Reset Profiles Vrati profile na početno + Arrowheads Vrhovi strelica + Edit Arrowheads Uredi vrhove strelica + Edit Arrowheads… Uredi vrhove strelica… + Reload Arrowheads Ponovo učitaj vrhove strelica Stroke Options Opcije poteza Width Profiles Profili širine Apply Graphic Style Primijeni grafički stil diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9c60e87e00..4ad2acfe23 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3507,6 +3507,10 @@ digits Malayalam Malaiàlam No settings found No s'ha trobat cap paràmetre Reset Printing and Proofing Restableix impressió i prova Reset Profiles Restableix els perfils + Arrowheads Puntes de fletxa + Edit Arrowheads Edita les puntes de fletxa + Edit Arrowheads… Edita les puntes de fletxa… + Reload Arrowheads Torna a carregar les puntes de fletxa Reset Rotate View Restableix la rotació de la vista Reset Ruler Origin Restableix l’origen de les regles Reset the rows to the artwork's colors Restableix les files als colors de l’obra diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e8f9666fae..75a65e2b2b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3870,6 +3870,10 @@ theme Appearance Motiv Add to Profiles Přidat do profilů Delete Profile Odstranit profil Reset Profiles Obnovit profily + Arrowheads Šipky + Edit Arrowheads Upravit šipky + Edit Arrowheads… Upravit šipky… + Reload Arrowheads Znovu načíst šipky Stroke Options Možnosti tahu Width Profiles Profily šířky Apply Graphic Style Použít grafický styl diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 31915ed6bf..171447e2f3 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3594,6 +3594,10 @@ theme Appearance Udseende Add to Profiles Føj til profiler Delete Profile Slet profil Reset Profiles Nulstil profiler + Arrowheads Pilespidser + Edit Arrowheads Redigér pilespidser + Edit Arrowheads… Redigér pilespidser… + Reload Arrowheads Genindlæs pilespidser Stroke Options Konturindstillinger Width Profiles Breddeprofiler Apply Graphic Style Anvend grafikstil diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c5424d81f..b926704b9c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2791,6 +2791,10 @@ digits Malayalam Malayalam Search Preferences Voreinstellungen durchsuchen No settings found Keine Einstellungen gefunden Reset Profiles Profile zurücksetzen + Arrowheads Pfeilspitzen + Edit Arrowheads Pfeilspitzen bearbeiten + Edit Arrowheads… Pfeilspitzen bearbeiten… + Reload Arrowheads Pfeilspitzen neu laden Rotate View Ansicht drehen Reset Rotate View Ansichtsdrehung zurücksetzen Rotate View to Selection Ansicht nach Auswahl drehen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6071ed5be4..d9048c58b2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3423,6 +3423,10 @@ digits Malayalam Μαλαγιάλαμ Reset OpenType Features Επαναφορά χαρακτηριστικών OpenType Reset Panel Επαναφορά πίνακα Reset Profiles Επαναφορά προφίλ + Arrowheads Αιχμές βελών + Edit Arrowheads Επεξεργασία αιχμών βελών + Edit Arrowheads… Επεξεργασία αιχμών βελών… + Reload Arrowheads Επαναφόρτωση αιχμών βελών Reset Rotate View Επαναφορά περιστροφής προβολής Rotate View to Selection Περιστροφή προβολής ως προς την επιλογή Reset the rows to the artwork's colors Επαναφορά γραμμών στα χρώματα του σχεδίου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a74e3ae39..d246f64bab 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2833,6 +2833,10 @@ digits Malayalam Malayálam Search Preferences Buscar en preferencias No settings found No se encontraron ajustes Reset Profiles Restablecer perfiles + Arrowheads Puntas de flecha + Edit Arrowheads Editar puntas de flecha + Edit Arrowheads… Editar puntas de flecha… + Reload Arrowheads Volver a cargar puntas de flecha Reset Rotate View Restablecer rotación de vista Rotate View to Selection Rotar vista según la selección Reset the rows to the artwork's colors Restablecer las filas a los colores de la ilustración diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2ec64f602..ec528e38d7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3794,6 +3794,10 @@ theme Appearance Ulkoasu Add to Profiles Lisää profiileihin Delete Profile Poista profiili Reset Profiles Palauta profiilit + Arrowheads Nuolenkärjet + Edit Arrowheads Muokkaa nuolenkärkiä + Edit Arrowheads… Muokkaa nuolenkärkiä… + Reload Arrowheads Lataa nuolenkärjet uudelleen Stroke Options Viivan asetukset Width Profiles Leveysprofiilit Apply Graphic Style Käytä grafiikkatyyliä diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 807b3363b4..e82137bdde 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2825,6 +2825,10 @@ digits Malayalam Malayalam Search Preferences Rechercher dans les préférences No settings found Aucun réglage trouvé Reset Profiles Réinitialiser les profils + Arrowheads Pointes de flèche + Edit Arrowheads Modifier les pointes de flèche + Edit Arrowheads… Modifier les pointes de flèche… + Reload Arrowheads Recharger les pointes de flèche Reset Rotate View Réinitialiser la rotation de l’affichage Rotate View to Selection Faire pivoter l’affichage selon la sélection Reset the rows to the artwork's colors Réinitialiser les lignes avec les couleurs de l’illustration diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779f..12fad0afa3 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3811,6 +3811,10 @@ antiAlias Sharp शार्प No settings found कोई सेटिंग नहीं मिली Reset Printing and Proofing मुद्रण और प्रूफिंग रीसेट करें Reset Profiles प्रोफ़ाइल रीसेट करें + Arrowheads एरोहेड्स + Edit Arrowheads एरोहेड्स संपादित करें + Edit Arrowheads… एरोहेड्स संपादित करें… + Reload Arrowheads एरोहेड्स फिर से लोड करें Reset Rotate View व्यू घुमाएँ रीसेट करें Reset Ruler Origin शासकीय उत्पत्ति रीसेट करें Reset Symbol Instance सिंबल इंस्टेंस रीसेट करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8c2d2339bc..8bd9b3730c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3377,6 +3377,10 @@ mark-style Japanese Japán No settings found Nincs ilyen beállítás Reset Printing and Proofing Printing and Proofing visszaállítása Reset Profiles Profilok visszaállítása + Arrowheads Nyílhegyek + Edit Arrowheads Nyílhegyek szerkesztése + Edit Arrowheads… Nyílhegyek szerkesztése… + Reload Arrowheads Nyílhegyek újratöltése Reset Rotate View Nézetforgatás visszaállítása Rotate View to Selection Nézet elforgatása a kijelöléshez Reset the rows to the artwork's colors Sorok visszaállítása a grafika színeire diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 606ffae97c..5665633eb8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3594,6 +3594,10 @@ theme Appearance Tampilan Add to Profiles Tambahkan ke profil Delete Profile Hapus profil Reset Profiles Atur ulang profil + Arrowheads Mata panah + Edit Arrowheads Edit mata panah + Edit Arrowheads… Edit mata panah… + Reload Arrowheads Muat ulang mata panah Stroke Options Opsi goresan Width Profiles Profil lebar Apply Graphic Style Terapkan gaya grafis diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bf4c9e7b58..e881a370fa 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2867,6 +2867,10 @@ digits Malayalam Malayalam Search Preferences Cerca nelle preferenze No settings found Nessuna impostazione trovata Reset Profiles Reimposta profili + Arrowheads Punte di freccia + Edit Arrowheads Modifica punte di freccia + Edit Arrowheads… Modifica punte di freccia… + Reload Arrowheads Ricarica punte di freccia Rotate View Ruota vista Reset Rotate View Reimposta rotazione vista Rotate View to Selection Ruota vista in base alla selezione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7a12afcadb..84ca0e4650 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2864,6 +2864,10 @@ mark-style Japanese 日本式 Search Preferences 環境設定を検索 No settings found 設定が見つかりません Reset Profiles プロファイルをリセット + Arrowheads 矢印 + Edit Arrowheads 矢印を編集 + Edit Arrowheads… 矢印を編集… + Reload Arrowheads 矢印を再読み込み Reset the rows to the artwork's colors 行をアートワークのカラーにリセット Reset to Default 初期設定に戻す Reset to White, Black 白、黒にリセット diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c58480c426..17bea31b37 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3433,6 +3433,10 @@ theme Appearance იერსახე Add to Profiles პროფილებში დამატება Delete Profile პროფილის წაშლა Reset Profiles პროფილების ჩამოყრა + Arrowheads ისრის წვერები + Edit Arrowheads ისრის წვერების რედაქტირება + Edit Arrowheads… ისრის წვერების რედაქტირება… + Reload Arrowheads ისრის წვერების ხელახლა ჩატვირთვა Stroke Options კონტურის პარამეტრები Width Profiles სიგანის პროფილები Apply Graphic Style გრაფიკული სტილის გამოყენება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 39efdecf2e..990684f2bc 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2853,6 +2853,10 @@ mark-style Japanese 일본식 Search Preferences 환경 설정 검색 No settings found 설정을 찾을 수 없음 Reset Profiles 프로필 재설정 + Arrowheads 화살촉 + Edit Arrowheads 화살촉 편집 + Edit Arrowheads… 화살촉 편집… + Reload Arrowheads 화살촉 다시 불러오기 Reset the rows to the artwork's colors 행을 아트웍 색상으로 재설정 Reset to Default 기본값으로 재설정 Reset to White, Black 흰색, 검정으로 재설정 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dd1d22edc0..15d0f9024a 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3608,6 +3608,10 @@ theme Appearance Vormgeving Add to Profiles Aan profielen toevoegen Delete Profile Profiel verwijderen Reset Profiles Profielen opnieuw instellen + Arrowheads Pijlpunten + Edit Arrowheads Pijlpunten bewerken + Edit Arrowheads… Pijlpunten bewerken… + Reload Arrowheads Pijlpunten opnieuw laden Stroke Options Lijnopties Width Profiles Breedteprofielen Apply Graphic Style Grafische stijl toepassen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd850a3a4a..f21124fd30 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3733,6 +3733,10 @@ theme Appearance Wygląd Add to Profiles Dodaj do profili Delete Profile Usuń profil Reset Profiles Resetuj profile + Arrowheads Groty strzałek + Edit Arrowheads Edytuj groty strzałek + Edit Arrowheads… Edytuj groty strzałek… + Reload Arrowheads Wczytaj ponownie groty strzałek Stroke Options Opcje obrysu Width Profiles Profile szerokości Apply Graphic Style Zastosuj styl graficzny diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f67ae6cda2..5b57e5962e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2370,6 +2370,10 @@ digits Malayalam Malaiala Search Preferences Pesquisar nas Preferências No settings found Nenhuma configuração encontrada Reset Profiles Redefinir Perfis + Arrowheads Pontas de Seta + Edit Arrowheads Editar Pontas de Seta + Edit Arrowheads… Editar Pontas de Seta… + Reload Arrowheads Recarregar Pontas de Seta Rotate View Girar Visualização Reset Rotate View Redefinir Visualização Girada Rotate View to Selection Girar Visualização para a Seleção diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8eb5425823..e6611285f7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2862,6 +2862,10 @@ digits Malayalam Малаялам Search Preferences Поиск в настройках No settings found Настройки не найдены Reset Profiles Сбросить профили + Arrowheads Стрелки + Edit Arrowheads Редактировать стрелки + Edit Arrowheads… Редактировать стрелки… + Reload Arrowheads Перезагрузить стрелки Rotate View Повернуть вид Reset Rotate View Сбросить поворот вида Rotate View to Selection Повернуть вид по выделению diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de0..5a310e2829 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2769,6 +2769,10 @@ digits Malayalam Malayalam Search Preferences Sök i inställningar No settings found Inga inställningar hittades Reset Profiles Återställ profiler + Arrowheads Pilspetsar + Edit Arrowheads Redigera pilspetsar + Edit Arrowheads… Redigera pilspetsar… + Reload Arrowheads Läs in pilspetsar igen Reset Rotate View Återställ Rotera vy Rotate View to Selection Rotera vyn efter markeringen Reset the rows to the artwork's colors Återställ raderna till teckningens färger diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d45a171cc6..a10b459531 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2638,6 +2638,10 @@ digits Malayalam Malayalam Search Preferences Tercihlerde Ara No settings found Ayar bulunamadı Reset Profiles Profilleri Sıfırla + Arrowheads Ok Uçları + Edit Arrowheads Ok Uçlarını Düzenle + Edit Arrowheads… Ok Uçlarını Düzenle… + Reload Arrowheads Ok Uçlarını Yeniden Yükle Reset Rotate View Döndürme Görünümünü Sıfırla Rotate View to Selection Görünümü Seçime Döndür Reset the rows to the artwork's colors Satırları resmin renklerine göre sıfırlayın diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index acaf20b6b9..b420758cfa 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2859,6 +2859,10 @@ digits Malayalam Малаялам Search Preferences Пошук у налаштуваннях No settings found Налаштування не знайдено Reset Profiles Скинути профілі + Arrowheads Стрілки + Edit Arrowheads Редагувати стрілки + Edit Arrowheads… Редагувати стрілки… + Reload Arrowheads Перезавантажити стрілки Rotate View Повернути вид Reset Rotate View Скинути поворот виду Rotate View to Selection Повернути вид за виділенням diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4a2bbd5a62..db91921932 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3140,6 +3140,10 @@ mark-style Japanese Kiểu Nhật Search Preferences Tìm trong tùy chọn No settings found Không tìm thấy cài đặt Reset Profiles Đặt lại hồ sơ màu + Arrowheads Đầu mũi tên + Edit Arrowheads Chỉnh sửa đầu mũi tên + Edit Arrowheads… Chỉnh sửa đầu mũi tên… + Reload Arrowheads Tải lại đầu mũi tên Rotate View Xoay chế độ xem Reset Rotate View Đặt lại xoay chế độ xem Rotate View to Selection Xoay chế độ xem theo vùng chọn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0eb7c7ebe5..56b4b7bcd6 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2733,6 +2733,10 @@ digits Malayalam 马拉雅拉姆数字 Search Preferences 搜索首选项 No settings found 未找到设置 Reset Profiles 重置配置文件 + Arrowheads 箭头 + Edit Arrowheads 编辑箭头 + Edit Arrowheads… 编辑箭头… + Reload Arrowheads 重新载入箭头 Reset Rotate View 重置旋转视图 Rotate View to Selection 按所选对象旋转视图 Reset the rows to the artwork's colors 将各行重置为图稿的颜色 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 084a7e7708..35b2f69cc4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3811,6 +3811,10 @@ digits Malayalam 馬來亞拉姆數字 Search Preferences 搜尋偏好設定 No settings found 找不到設定 Reset Profiles 重設描述檔 + Arrowheads 箭頭 + Edit Arrowheads 編輯箭頭 + Edit Arrowheads… 編輯箭頭… + Reload Arrowheads 重新載入箭頭 Rotate View 旋轉檢視 Reset Rotate View 重設旋轉檢視 Rotate View to Selection 依選取範圍旋轉檢視 diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index f6ece0ac55..7be5b65e7c 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -100,10 +100,37 @@ pub(crate) fn edited_dash_pattern(fields: &[Option; 6], previous: &[f64]) - out } -fn arrow_label(a: Option) -> &'static str { +fn arrow_label(a: Option<&Arrowhead>) -> &'static str { a.map_or("None", Arrowhead::label) } +/// The name an arrowhead menu shows for `a`: a built-in head's in the interface language, a +/// custom head's own. +fn arrow_name(a: Option<&Arrowhead>) -> String { + match a { + Some(Arrowhead::Custom(c)) => c.name.clone(), + _ => tl!(arrow_label(a)).to_string(), + } +} + +/// The preview cache key of `a`: its name, and for a custom head its art. +fn arrow_key(a: Option<&Arrowhead>) -> String { + match a { + Some(Arrowhead::Custom(c)) => { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + for s in &c.path.subpaths { + (s.anchors.len(), s.closed).hash(&mut h); + for p in s.anchors.iter().flat_map(|a| [a.p, a.h_in, a.h_out]) { + (p.x.to_bits(), p.y.to_bits()).hash(&mut h); + } + } + format!("custom:{}:{:x}", c.name, h.finish()) + } + _ => format!("{a:?}"), + } +} + /// Apply Stroke panel options (`stroke.set` params): while the Type tool edits text, to the /// selected characters' stroke. fn set(app: &mut VectorcraftApp, p: Value) { @@ -272,14 +299,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); widgets::divider(ui); // Arrowheads. - let (sa, ea) = (st.as_ref().and_then(|s| s.start_arrow), st.as_ref().and_then(|s| s.end_arrow)); + let (sa, ea) = (st.as_ref().and_then(|s| s.start_arrow.clone()), st.as_ref().and_then(|s| s.end_arrow.clone())); + // The custom heads of the arrowheads file follow the built-in ones. + let custom: Vec = app.session.arrowheads.heads().iter().cloned().map(Arrowhead::Custom).collect(); ui.horizontal(|ui| { row_label(ui, tl!("Arrowheads:")); - if let Some(a) = arrow_dropdown(ui, "arrow-start", sa, true) { - set(app, json!({"startArrow": a.map(|a| format!("{a:?}"))})); + // A custom head goes with its art, as document.inspect reports it. + if let Some(a) = arrow_dropdown(ui, "arrow-start", sa.as_ref(), &custom, true) { + set(app, json!({ "startArrow": a })); } - if let Some(a) = arrow_dropdown(ui, "arrow-end", ea, false) { - set(app, json!({"endArrow": a.map(|a| format!("{a:?}"))})); + if let Some(a) = arrow_dropdown(ui, "arrow-end", ea.as_ref(), &custom, false) { + set(app, json!({ "endArrow": a })); } if widgets::icon_button_enabled(ui, "arrow-left-right", tl!("Swap start and end arrowheads"), false, st.is_some(), 22.0).clicked() { app.run("stroke.setAdvanced", json!({"swapArrows": true})).ok(); @@ -360,14 +390,15 @@ pub(super) fn arrow_doc(a: Option, start: bool, w: f64, h: f64) -> Op /// Draw an arrowhead preview (the head as the canvas draws it) in `r`: a line with the head at the /// left (`start`) or right end. -fn paint_arrow(ui: &Ui, r: Rect, a: Option, start: bool, color: Color32) { - if let Some(tex) = widgets::doc_preview(ui, &format!("arrow:{a:?}:{start}"), r.size(), |w, h| arrow_doc(a, start, w, h)) { +fn paint_arrow(ui: &Ui, r: Rect, a: Option<&Arrowhead>, start: bool, color: Color32) { + if let Some(tex) = widgets::doc_preview(ui, &format!("arrow:{}:{start}", arrow_key(a)), r.size(), |w, h| arrow_doc(a.cloned(), start, w, h)) { ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), color); } } -/// Dropdown whose button and items show drawn arrowhead previews. Returns Some(choice). -fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option, start: bool) -> Option> { +/// Dropdown whose button and items show drawn arrowhead previews: None, the built-in heads, then +/// the `custom` ones. Returns Some(choice). +fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option<&Arrowhead>, custom: &[Arrowhead], start: bool) -> Option> { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(64.0, 24.0), Sense::click()); ui.painter().rect_filled(r, 2, t.input); @@ -380,7 +411,8 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option, start: bool) -> egui::Popup::menu(&resp).id(egui::Id::unique(("arrow-pop", id))).show(|ui| { ui.set_min_width(170.0); egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for a in std::iter::once(None).chain(Arrowhead::ALL.map(Some)) { + let built_in = Arrowhead::ALL; + for a in std::iter::once(None).chain(built_in.iter().map(Some)).chain(custom.iter().map(Some)) { let (row, rr) = ui.allocate_exact_size(vec2(170.0, 22.0), Sense::click()); if a == cur { ui.painter().rect_filled(row, 0.0, t.row_selected); @@ -391,12 +423,12 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option, start: bool) -> ui.painter().text( row.left_center() + vec2(66.0, 0.0), egui::Align2::LEFT_CENTER, - tl!(arrow_label(a)), + arrow_name(a), egui::FontId::proportional(11.5), t.text, ); if rr.clicked() { - out = Some(a); + out = Some(a.cloned()); ui.close(); } } @@ -500,6 +532,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Reset Profiles"), crate::menus::enabled(app, "stroke.widthProfile.reset"), false) { app.run("stroke.widthProfile.reset", json!({})).ok(); } + ui.separator(); + // Opens the arrowheads file, whose symbols are the custom heads; saving it reloads them. + if menu_item(ui, tl!("Edit Arrowheads…"), crate::menus::enabled(app, "stroke.arrowheads.edit"), false) { + app.run("stroke.arrowheads.edit", json!({})).ok(); + } } #[cfg(test)] @@ -647,7 +684,7 @@ mod tests { assert!(silhouette(WidthProfile::PRESETS[0].points).is_none(), "uniform draws the plain bar"); let lens = WidthProfile::lens(); assert_eq!(silhouette(&lens.points), Some(lens.points.as_slice())); - assert_eq!(arrow_label(Some(Arrowhead::CircleOpen)), "Circle (open)"); + assert_eq!(arrow_label(Some(&Arrowhead::CircleOpen)), "Circle (open)"); } #[test] diff --git a/crates/ui-egui/src/panels/tests_strokedepth.rs b/crates/ui-egui/src/panels/tests_strokedepth.rs index e37b6e75b7..c085ccf385 100644 --- a/crates/ui-egui/src/panels/tests_strokedepth.rs +++ b/crates/ui-egui/src/panels/tests_strokedepth.rs @@ -100,9 +100,15 @@ fn every_arrowhead_preview_draws_its_head() { }; let plain = (ink(None, false), ink(None, true)); for a in Arrowhead::ALL { - assert!(ink(Some(a), false) > plain.0 + 20, "{a:?} at the end"); - assert!(ink(Some(a), true) > plain.1 + 20, "{a:?} at the start"); + assert!(ink(Some(a.clone()), false) > plain.0 + 20, "{a:?} at the end"); + assert!(ink(Some(a.clone()), true) > plain.1 + 20, "{a:?} at the start"); } + // So does a custom head: a 4 × 4 square behind its tip. + let pts = [(0.0, -2.0), (4.0, -2.0), (4.0, 2.0), (0.0, 2.0)].map(|(x, y)| vectorcraft_geom::Point::new(x, y)); + let square = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)); + let custom = Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new("Square".into(), square))); + assert!(ink(Some(custom.clone()), false) > plain.0 + 20); + assert!(ink(Some(custom), true) > plain.1 + 20); } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..c6a9d363e2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3741,6 +3741,31 @@ the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `Dou `arrowScale`, at least 0.25 pt. `LineArrow` has two arms of the head's weight (the line's weight at 100 % for lines of 0.25 pt or more) at right angles and spreads twice as wide. +Custom arrowheads come from the arrowheads file, `Arrowheads.vectorcraft` next to the preferences (none on the web or +in runs with `VECTORCRAFT_NO_PREFS`). `stroke.arrowheads.edit` (Stroke panel menu › Edit Arrowheads…) opens it, and +first creates it with one example head when it is missing. Each symbol in the file is one head, named after the +symbol and listed after the built-in heads in both arrowhead menus. These rules are VectorCraft's own: a head points +right, the symbol's registration point is its tip, and the art's height is drawn as wide as the built-in heads (four +head weights). The visible fills and the outlined visible strokes of its paths, compound paths and groups, and its +gradient meshes, make the head, with geometry effects applied and clip groups clipped, and the line stops halfway along its length. A head is +one solid shape in the stroke's paint: art at 0 % opacity is left out, and other opacities, blend modes and opacity +masks do not change the shape. The file is read when the app starts and again each time VectorCraft saves it; +`stroke.arrowheads.reload` reads it again. A file larger than 64 MB is not read, at most 256 symbols are read, and a +symbol that paints nothing, reaches more than 64 head weights from its tip, has more than 65,536 anchors in its art or +in the shapes it paints, or has more than 4,096 anchors once united is listed in `skipped`. `stroke.arrowheads.list` returns each head's `name`, `reach` and `halfWidth`, the `file` +path, the `skipped` symbols and any read `error`. + +`stroke.set {endArrow: {"custom": name}}` puts a custom head on a stroke. The stroke keeps a copy of the head's art, +so the document draws it, saves it and exports it to SVG and PDF the same way without the arrowheads file. +`document.inspect` reports such a head as `{"Custom": {name, path}}`, which `stroke.set` takes back unchanged. The +help page [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) describes +defining custom arrowheads in a separate arrowheads file. + +```json +{"name":"run_command","arguments":{"command":"stroke.arrowheads.list","params":{}}} +{"name":"run_command","arguments":{"command":"stroke.set","params":{"ids":[9],"endArrow":{"custom":"Example Arrowhead"}}}} +``` + ## Stroke bounds and clicks Visual bounds (Fit to Selected Art, Rasterize, exporting all art) and clicks on the canvas take diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 296826671f..15326c0347 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -406,6 +406,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Select › Start Global Edit / Stop Global Edit; Properties › Quick Actions with the Global Edit Options popover (Match: Appearance, Size; On Artboards: All Artboards or a range, Include Objects On Canvas; Result(s)); Escape stops it; the matched objects are highlighted in their layer colour while it runs; a Shift-click on a highlighted match leaves it out | partial | #3521, `select.globalEdit` / `select.globalEditOptions` / `select.globalEditExclude`, `crates/engine/src/tests/globaledit.rs`; paths, compound paths, symbol instances and groups of them (similar groups, #4939: edits inside one group reach the same object in the others; [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)); Shift-click to leave a match out ([public source](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)); the Control bar's Global Edit Options dropdown opens the same options; rotated copies and highlighting matches before the start remain ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)) | | Contextual task bar | done | | | Control bar with Transform and Stroke popovers | done | | +| Stroke panel: custom arrowheads after the built-in ones in both arrowhead menus, from the arrowheads file that the panel menu's Edit Arrowheads… opens; saving the file reloads the menus, and a stroke keeps a copy of its custom head's art | done | [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) (Customize arrowheads); the file's layout (one symbol per head, pointing right, tip at the registration point, height drawn as wide as the built-in heads) is VectorCraft's own; `stroke.arrowheads.edit`, `stroke.arrowheads.list`, `stroke.arrowheads.reload`, `stroke.set` `{custom: name}`; engine `tests::arrowheads`, `stroke::tests_arrows::custom_heads_draw_their_own_art_at_the_tip` | | Control bar: Paragraph: link and Align left/center/right while type is selected (#5372) | done | After the Character controls, as the public documentation has the Control panel format paragraphs ([Paragraph panel overview](https://helpx.adobe.com/illustrator/using/formatting-paragraphs.html), [Align text](https://helpx.adobe.com/illustrator/using/align-text.html)); the link opens the Paragraph panel in a popover, as Character: does with the Character panel. The bar trims from its right end: X/Y/W/H, Distribute, Align, area type vertical alignment, then these buttons, then the link. The three-button set is our choice for room; all seven alignments are in the popover. `panels/paragraph/tests.rs` headless tests (`the_control_bar_shows_the_paragraph_alignment_of_the_selected_type`, `the_control_bar_trims_its_paragraph_section_after_the_controls_to_its_right`) | | Pathfinder shape results keep their source names and SVG IDs | done | #1293; Minus Front keeps the back name, Minus Back the front name; Unite/Intersect/Exclude use the backmost named input; compound output carries it once; geometry and paint unchanged | | Attributes fill-rule controls with a compound member selected | done | Edits the compound's rule; direction still edits only the member; rendered-hole and Undo/Redo regression tests (#1197) | From b42a3d4869dbfe77042f376c2cb60c74a3168d97 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 23:33:02 -0800 Subject: [PATCH 02/34] Stroke: stroke.set refuses a custom arrowhead path that fills no area A {Custom: {name, path}} head is refused when its path fills no area under the non-zero rule: a single anchor, points on one line, or contours that cancel out. A bow tie and a thin bar are taken. The params text and docs/mcp.md state the rule. --- crates/engine/src/cmd/stroke.rs | 17 +++++++++++--- crates/engine/src/tests/arrowheads.rs | 33 +++++++++++++++++++++++++++ docs/mcp.md | 8 ++++--- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 49b3128f61..40b2a7aa86 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", has_doc, stroke_set ), @@ -234,9 +234,20 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { Some(Value::Object(o)) if o.contains_key("Custom") => { let raw = o.get("Custom").and_then(|c| c.get("path")).cloned().unwrap_or(Value::Null); let path: PathData = serde_json::from_value(raw).map_err(|e| bad(C, format!("{k}: bad custom arrowhead path: {e}")))?; - if path.subpaths.iter().all(|sp| sp.anchors.is_empty()) || !CustomArrowhead::fits(&path) { + // The region the outline fills (non-zero rule), down to slivers a millionth of a + // head unit thin. Measured only once `fits` has bounded the anchors. + let fills = |p: &PathData| { + let area = vectorcraft_pathops::unite_all_with(&[(p, vectorcraft_geom::FillRule::NonZero)], 1e-6); + area.subpaths.iter().map(|sp| sp.area().abs()).sum::() > 1e-12 + }; + if !CustomArrowhead::fits(&path) || !fills(&path) { let (most, far) = (CustomArrowhead::MAX_ANCHORS, CustomArrowhead::MAX_UNITS); - return Err(bad(C, format!("{k}: a custom arrowhead's path needs 1 to {most} anchors, each within {far} head units of its tip"))); + return Err(bad( + C, + format!( + "{k}: a custom arrowhead's path needs an area to fill and at most {most} anchors, each within {far} head units of its tip" + ), + )); } let head = p.get(k).cloned().unwrap_or(Value::Null); serde_json::from_value::(head).map(|a| Some(Some(a))).map_err(|e| bad(C, format!("{k}: bad custom arrowhead: {e}"))) diff --git a/crates/engine/src/tests/arrowheads.rs b/crates/engine/src/tests/arrowheads.rs index 1e5c1fe5a8..2fdbbebca9 100644 --- a/crates/engine/src/tests/arrowheads.rs +++ b/crates/engine/src/tests/arrowheads.rs @@ -194,7 +194,40 @@ fn the_arrowheads_file_is_made_opened_and_reloaded_when_saved() { let mut empty = serde_json::to_value(Arrowhead::Custom(c)).unwrap(); empty["Custom"]["path"] = json!({"subpaths": []}); assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": empty})).is_err()); + // One anchor, or anchors on one line, fill nothing. + for pts in [vec![Point::new(1.0, 1.0)], vec![Point::new(0.0, 0.0), Point::new(2.0, 0.0), Point::new(4.0, 0.0)]] { + let mut flat = + serde_json::to_value(Arrowhead::Custom(Arc::new(vectorcraft_doc::CustomArrowhead::new("Flat".into(), PathData::default())))).unwrap(); + flat["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&pts, false))).unwrap(); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": flat})).is_err(), "{pts:?}"); + } + // A lone anchor draws no segment, whatever its handles. + let mut lone = vectorcraft_geom::Anchor::corner(Point::new(1.0, 0.0)); + (lone.h_in, lone.h_out) = (Point::new(0.0, 1.0), Point::new(2.0, -1.0)); + let mut handles = + serde_json::to_value(Arrowhead::Custom(Arc::new(vectorcraft_doc::CustomArrowhead::new("Lone".into(), PathData::default())))).unwrap(); + handles["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::new(vec![lone], true))).unwrap(); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": handles.clone()})).is_err(), "a lone anchor"); + // Two equal squares winding opposite ways cancel out. + let sq = [Point::new(0.0, -1.0), Point::new(2.0, -1.0), Point::new(2.0, 1.0), Point::new(0.0, 1.0)]; + let back: Vec = sq.iter().rev().copied().collect(); + let cancel = PathData::new(vec![SubPath::polyline(&sq, true), SubPath::polyline(&back, true)]); + handles["Custom"]["path"] = serde_json::to_value(cancel).unwrap(); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": handles})).is_err(), "opposite squares"); + // A bow tie's lobes wind opposite ways, so its signed area is 0, and it still fills both. + let tie = [Point::new(0.0, -1.0), Point::new(2.0, 1.0), Point::new(2.0, -1.0), Point::new(0.0, 1.0)]; + let mut bow = + serde_json::to_value(Arrowhead::Custom(Arc::new(vectorcraft_doc::CustomArrowhead::new("Bow".into(), PathData::default())))).unwrap(); + bow["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&tie, true))).unwrap(); assert!(matches!(stroke(&s, id).end_arrow, Some(Arrowhead::Custom(h)) if h.name == "Square"), "the head stays as it was"); + s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": bow.clone()})).unwrap(); + assert!(matches!(stroke(&s, id).end_arrow, Some(Arrowhead::Custom(h)) if h.name == "Bow")); + // So does a very thin bar. + let bar = [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 0.001), Point::new(0.0, 0.001)]; + bow["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&bar, true))).unwrap(); + s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": bow})).unwrap(); + let Some(Arrowhead::Custom(h)) = stroke(&s, id).end_arrow else { panic!("a custom head") }; + assert_eq!(h.path.anchor_count(), 4, "the thin bar"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/docs/mcp.md b/docs/mcp.md index 02f83c6cc5..aa6f7bcd69 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3968,9 +3968,11 @@ path, the `skipped` symbols and any read `error`. `stroke.set {endArrow: {"custom": name}}` puts a custom head on a stroke. The stroke keeps a copy of the head's art, so the document draws it, saves it and exports it to SVG and PDF the same way without the arrowheads file. -`document.inspect` reports such a head as `{"Custom": {name, path}}`, which `stroke.set` takes back unchanged. The -help page [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) describes -defining custom arrowheads in a separate arrowheads file. +`document.inspect` reports such a head as `{"Custom": {name, path}}`, which `stroke.set` takes back unchanged. It +refuses a `path` that fills no area (non-zero rule; parts thinner than a millionth of a head unit count as none) or +has more than 4,096 anchors or a point more than 64 head units from the tip. The help page +[Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) describes defining custom +arrowheads in a separate arrowheads file. ```json {"name":"run_command","arguments":{"command":"stroke.arrowheads.list","params":{}}} From 46ce6364c5de96c106fd03ada2261c1da50639aa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 23:35:26 -0800 Subject: [PATCH 03/34] MCP: document.inspect and document.json leave out paths outside a confined client's read roots Under --automation-read-root, document.inspect gives `path` as null when the document's file is outside the read roots. document.json names a linked image or placed file outside them by its file name alone, without its relative path, size or date, in the layers, symbols, graph designs, patterns, the pattern being edited and opacity masks. It also names a data set's image value outside them by its file name, and leaves the Export for Screens folder outside them out of export_settings. The document is not changed, and unconfined callers see the same replies as before. --- crates/engine/src/cmd/edit.rs | 8 +++- crates/engine/src/cmd/links.rs | 46 ++++++++++++++++++- crates/engine/src/inspect.rs | 3 +- crates/engine/src/tests/placed_document.rs | 52 ++++++++++++++++++++++ docs/mcp.md | 5 +++ docs/target-app-parity.md | 1 + 6 files changed, 111 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 108b8593c9..f050684cd3 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -38,7 +38,7 @@ pub fn specs() -> Vec { has_doc, doc_move ), - 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 (0–1) and opacityPct (0–100 %, the scale object.setProps takes) 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.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)} → path (the document's file; null when automation read roots are in force and the file is outside them), layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) 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, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find), cmd!( @@ -50,7 +50,11 @@ pub fn specs() -> Vec { has_doc, doc_compare ), - 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))), + cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, and so is a data set's image value outside them; a folder outside them is left out of export_settings)", has_doc, |s, _| { + let st = s.doc()?; + let d = super::links::with_hidden_paths_named(&st.doc, super::links::LinkBase::of(st).doc); + Ok(serde_json::to_value(&*d).unwrap_or(Value::Null)) + }), cmd!( "document.setUnits", "Units", diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index a134eb1c55..effa917d8e 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -28,7 +28,9 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; -use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions}; +use vectorcraft_doc::{ + Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions, +}; use vectorcraft_geom::{Affine, PathData, Rect, shapes}; use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file}; @@ -1195,6 +1197,48 @@ pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) { }); } +/// `d`, a document saved at `doc_path`, as a reply to the caller asking now may show it: without +/// read roots in force, `d` itself; with them, a copy in which each link [`path_shown`] does not +/// allow names its file alone, without its relative path or the size and date kept of its file, +/// and each data set's image value it does not allow names its file alone too. The folder Export +/// for Screens last wrote to is left out of the export settings when it is outside the read roots. +/// Links in the layers, symbols, graph designs, patterns, the pattern being edited as it was +/// before and opacity masks are all checked. +pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, doc_path: Option<&str>) -> Cow<'a, Document> { + if crate::file_access::current().is_none() { + return Cow::Borrowed(d); + } + let mut d = d.clone(); + // The pattern being edited as it was before is checked as one more pattern, then put back. + let original = d.pattern_edit.as_mut().and_then(|e| e.original.take()); + let edited = original.is_some(); + d.patterns.extend(original); + d.update_links_where( + |l| !path_shown(&l.path, doc_path), + |l| { + let name = l.name().to_string(); + *l = LinkInfo { path: name, relative: None, modified: None, size: None, ..l.clone() }; + }, + ); + if edited && let (Some(def), Some(e)) = (d.patterns.pop(), d.pattern_edit.as_mut()) { + e.original = Some(def); + } + if d.export_settings.get("folder").and_then(Value::as_str).is_some_and(|f| crate::file_access::check_read(f).is_err()) { + d.export_settings.remove("folder"); + } + let hidden = |v: &DataValue| matches!(v, DataValue::Image(path) if !path_shown(path, doc_path)); + if d.variables.datasets.iter().any(|set| set.values.values().any(hidden)) { + for v in d.variables.datasets.iter_mut().flat_map(|set| set.values.values_mut()) { + if hidden(&*v) + && let DataValue::Image(path) = v + { + *path = LinkInfo::new(path.as_str()).name().to_string(); + } + } + } + Cow::Owned(d) +} + // ---------- commands ---------- fn check(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 9bad62a6c8..05cc8fe77e 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -287,7 +287,8 @@ fn document_base(s: &Session) -> Value { "title": st.title(), // The File Info title (`file.info`), where `title` comes from the file name once the document has one. "fileInfoTitle": d.title, - "path": st.path, + // Null for a confined caller when the file is outside its read roots. + "path": st.path.as_deref().filter(|p| crate::file_access::check_read(p).is_ok()), "dirty": st.is_dirty(), "revision": st.revision, "units": d.units.label(), diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index e7d3c7cd7a..3ec0310430 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -1084,6 +1084,58 @@ fn link_info_leaves_out_a_placed_documents_path_outside_the_read_roots() { assert_eq!((&i["link"], &i["path"]), (&json!(src), &json!(src)), "unconfined: {i}"); } +/// `document.inspect` and `document.json` for a confined caller: the document's file, a placed +/// document and a linked image outside the read roots are not named by path, and the document +/// keeps its links. +#[test] +fn document_replies_leave_out_paths_outside_the_read_roots() { + let away = Folder::new("doc-lrr-away"); + let home = Folder::new("doc-lrr-home"); + let src = away.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let pic = away.file("photo.png"); + write(&pic, &crate::tests_links::png(60, 30, BLUE)); + let mut s = session(); + let id = place(&mut s, &src); + let r = s.execute("file.place", &json!({"path": pic, "at": [50, 50]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + s.execute("variable.define", &json!({"name": "Photo", "kind": "image"})).unwrap(); + s.execute("dataset.new", &json!({"name": "Row", "values": {"Photo": pic}})).unwrap(); + let image = r["ids"][0].as_u64().unwrap(); + let poster = away.file("poster.vectorcraft"); + save(&mut s, &poster); + // The folder Export for Screens wrote to, and a pattern of the linked image being edited: its + // definition before editing is kept beside the tile art. + let out = away.file("out"); + s.execute("document.exportForScreens", &json!({"folder": out, "formats": [{"format": "svg"}]})).unwrap(); + let pattern = s.execute("object.pattern.make", &json!({"ids": [image], "edit": false})).unwrap()["name"].clone(); + s.execute("object.pattern.edit", &json!({"name": pattern})).unwrap(); + assert!(s.doc().unwrap().doc.pattern_edit.as_ref().is_some_and(|e| e.original.is_some())); + let ask = |s: &mut Session, root: &std::path::Path, cmd: &str| { + let roots = crate::file_access::AutomationRoots::new(&[root], &[]).unwrap().unwrap(); + crate::file_access::confine(Some(&roots), || s.execute(cmd, &json!({}))).unwrap() + }; + let inspect = ask(&mut s, &home.0, "document.inspect"); + assert!(!inspect.to_string().contains("doc-lrr-away"), "{inspect}"); + assert!(inspect["path"].is_null() && inspect["uid"].is_u64(), "{inspect}"); + let model = ask(&mut s, &home.0, "document.json").to_string(); + assert!(!model.contains("doc-lrr-away"), "{model}"); + for name in ["logo.vectorcraft", "photo.png"] { + assert!(model.contains(&format!("\"path\":\"{name}\"")), "{name}: {model}"); + } + assert!(model.contains("{\"image\":\"photo.png\"}"), "{model}"); + // The document keeps its links, and a caller whose read roots hold the files sees every path. + assert_eq!(placed(&s, id).link.path, src); + for model in [ask(&mut s, &away.0, "document.json"), s.execute("document.json", &json!({})).unwrap()] { + let model = model.to_string(); + for path in [&src, &pic, &out] { + assert!(model.contains(&json!(path).to_string()), "{path}: {model}"); + } + } + assert_eq!(ask(&mut s, &away.0, "document.inspect")["path"], json!(poster)); + assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["path"], json!(poster)); +} + /// Save As reads its defaults from the catalog; using them must retain current editable links. #[test] fn native_save_catalog_defaults_keep_placed_documents_editable() { diff --git a/docs/mcp.md b/docs/mcp.md index aeed51157a..8a5a05b9be 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -158,6 +158,11 @@ the suite) keep the agent to the folders you give it: `null`; an empty folder and the scratch disk choices `Startup` and `None` stay. In `prefs.get {}` and `prefs.reset`, an export preset in `screenExportPresets` leaves out its `folder` when the folder is outside the read roots, and the preset stays. The preferences themselves are not changed. +- `document.inspect` (`inspect_document`) gives `path` as null when the document's file is outside the read roots. + `document.json` names a linked image or placed file outside them by its file name alone in the link's `path`, and + leaves out the link's `relative`, `size` and `modified`. It also names a data set's image value outside them by + its file name alone, and leaves out the `folder` of `export_settings` (the settings Export for Screens last used) + when that folder is outside them. The document itself is not changed. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5e0cf03a3a..b5fdf7e26b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -760,6 +760,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). + - Document replies under `--automation-read-root`: `document.inspect` gives `path` as null for a document file outside the read roots, and `document.json` names a linked image or placed file outside them by its file name alone, without its relative path, size or date, and a data set's image value outside them by its file name, and leaves an Export for Screens folder outside them out of `export_settings`; the document is not changed. `crates/engine/src/tests/placed_document.rs` (`document_replies_leave_out_paths_outside_the_read_roots`). - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. From ba34df9543f4feee27d30f3c8e462d74a49c785c Mon Sep 17 00:00:00 2001 From: jmverges Date: Thu, 12 Nov 2026 10:52:44 +0000 Subject: [PATCH 04/34] .ai import: type filled or stroked with a pattern keeps its pattern (#6856) In the text document of a .ai's editing copy, a character's fill (key 53) or stroke (key 54) is a paint with a colour under key 0 and, under key 5, the name of an art style of the editing data. For a pattern swatch, key 0 is black and the pattern is only in that style: a style of one paint (/SimpleStyle) that paints with `(name) ... [matrix] p`. The reader took key 0 alone, so type painted with a pattern came in black. The art style reader now keeps the pattern of a style that is one paint, and the import places it as a path's pattern fill would be (its matrix in art space; the files' own pages tile such type with it), by the style's name. A run whose fill or stroke is on and names such a style takes the pattern, in the type's text space, so its tiles stay where the document has them. A pattern the file doesn't define or can't place, a key 5 that isn't a string, and a style of any other paint keep the colour as before. A style that also has filters gives no pattern for type, and a pattern that can't be placed in the type's text space keeps the colour. A .ai saved again writes such type as outlines with a warning, as it already does for any type with pattern paint (#5965). Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/read.rs | 4 + crates/eps/src/import/ai/read/document.rs | 12 ++ crates/eps/src/import/ai/read/ops.rs | 41 ++++-- crates/eps/src/import/ai/style.rs | 74 ++++++++--- crates/eps/src/import/ai/style/tests.rs | 15 +++ crates/eps/src/import/ate.rs | 153 ++++++++++++++++++++-- crates/eps/src/import/native/build.rs | 3 +- crates/eps/src/import/tests_native.rs | 65 +++++++++ docs/file-format-parity.md | 8 +- 9 files changed, 335 insertions(+), 40 deletions(-) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 5a253f21a3..3721ce7960 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -205,6 +205,10 @@ pub struct Structure { pub warped_type: super::envelope::WarpedType, /// The document's graphic styles are those of the file's style list, in place of the defaults. pub styles_read: bool, + /// The patterns that paint the text document's characters, placed in the document, by the + /// name of the art style that gives each: the styles that are one paint, a pattern this read + /// (see `ate::Texts::with_patterns`). + pub text_patterns: HashMap, /// The file's `%AI24_LargeCanvasScale` (1 when it declares none), applied or not. pub canvas_scale: f64, /// The objects by their XML id (`AI10_ArtUID`), a text object by its slot's id: `None` for an diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index ad7f266036..ca943e8817 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1335,6 +1335,17 @@ impl<'a> Reader<'a> { doc.limit_depth_noting(&mut warnings); } warnings.extend(self.names.notes()); + // The patterns of the styles that are one paint, which the text document's characters + // name for their fills and strokes: those that place in the document. + let text_patterns = self + .styles + .iter() + .filter_map(|(style, st)| { + let Some(crate::import::ai::style::StyleItem::Pattern { name, matrix, controls }) = &st.simple_pattern else { return None }; + let paint = super::ops::style_pattern(name, *matrix, controls, &self.pattern_tiles, &self.v7_patterns, &self.global_names, to_doc)?; + Some((style.clone(), paint)) + }) + .collect(); Ok(Structure { doc, to_doc, @@ -1358,6 +1369,7 @@ impl<'a> Reader<'a> { page, }, styles_read, + text_patterns, canvas_scale: self.canvas_scale, xml_ids: self.xml_ids, bindings, diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index c0d9852d88..82f16c04a9 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -943,6 +943,29 @@ fn pattern_xf(space: Affine, matrix: Option, tile: Rect) -> Option= 1e-12 && xf.as_coeffs().iter().all(|v| v.is_finite())).then_some(xf) } +/// A style's pattern (`StyleItem::Pattern`): the file's pattern `name` placed by `matrix` (its +/// space → art space; `controls` apply first for a pattern defined in the version 7 form, `v7` +/// those patterns' names) and carried into the document by `space`, with the name `names` give +/// it there. `None` when the pattern wasn't read (`tiles`: the tiles of those that were, by their +/// names in the file) or doesn't place. +pub(super) fn style_pattern( + name: &str, + matrix: Option<[f64; 6]>, + controls: &[f64], + tiles: &HashMap, + v7: &HashSet, + names: &ShownNames, + space: Affine, +) -> Option { + let tile = tiles.get(name).copied()?; + let mut matrix = matrix.and_then(|m| paint::affine(&m)); + if v7.contains(name) { + matrix = Some(matrix.unwrap_or(Affine::IDENTITY) * pattern_controls(controls)?); + } + let xf = pattern_xf(space, matrix, tile)?; + Some(Paint::Pattern { pattern: names.get(Global::Pattern, name).to_string(), xf }) +} + /// A pattern fill's `x y scaleX scaleY angle reflect axis shear shearAxis` as one transform of the /// pattern's space, applied before its initial matrix: the shear (by `shear` degrees along the line /// at `shearAxis` degrees), the reflection about the line at `axis` degrees when `reflect` isn't @@ -1147,15 +1170,15 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p } StyleItem::Pattern { name, matrix, controls } => match placed.get(&format!("{PATTERN_KEY}{}", paints.names.get(Global::Pattern, name))) { Some(p) => AppearanceItem::Fill(FillLayer::new(p.clone())), - None => { - let tile = paints.patterns.get(name).copied()?; - let mut matrix = matrix.and_then(|m| paint::affine(&m)); - if paints.v7_patterns.contains(name) { - matrix = Some(matrix.unwrap_or(Affine::IDENTITY) * pattern_controls(controls)?); - } - let xf = pattern_xf(paints.space, matrix, tile)?; - AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: paints.names.get(Global::Pattern, name).to_string(), xf })) - } + None => AppearanceItem::Fill(FillLayer::new(style_pattern( + name, + *matrix, + controls, + paints.patterns, + paints.v7_patterns, + paints.names, + paints.space, + )?)), }, StyleItem::Stroke { brush, .. } => { // A brushed stroke draws as its look only with the brush the Brushes panel keeps diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index f15744f30c..a884fd9296 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -202,6 +202,10 @@ pub(super) struct ArtStyle { pub transparency: Option<(f32, BlendMode, bool, Knockout)>, /// [`Self::effects`] holds Effect › Pathfinder effects, which only a group is read with. pub pathfinder: bool, + /// A style that is one paint (`/SimpleStyle :`, its paint's operators, `/Paint ;`), when that + /// paint is a pattern: its [`StyleItem::Pattern`]. A text document's characters name such + /// styles for their fills and strokes (`ate`). `None` for any other paint or style. + pub simple_pattern: Option, } impl ArtStyle { @@ -406,9 +410,14 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap = vec![]; let mut pending: Option = None; let mut in_basic = false; - let mut finish = |name: Option, filters: &mut Vec| { + // A style that is one paint being read, and its pattern so far. + let mut simple: Option> = None; + let mut finish = |name: Option, filters: &mut Vec, simple_pattern: Option| { if let Some(name) = name { - styles.insert(name, style(filters)); + // Only a style that is one paint holds a pattern for type: one with filters as well + // paints with more than that. + let simple_pattern = simple_pattern.filter(|_| filters.is_empty()); + styles.insert(name, ArtStyle { simple_pattern, ..style(filters) }); } filters.clear(); }; @@ -445,11 +454,12 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap match n { b"KnownStyle" => { - finish(name.take(), &mut filters); + finish(name.take(), &mut filters, simple.take().flatten()); operands.clear(); open.clear(); (pending, in_basic, style_entry, other_entry) = (None, false, None, None); } + b"SimpleStyle" if filters.is_empty() => simple = Some(None), b"FillStyle" => style_entry = Some(Kind::Fill), b"StrokeStyle" | b"BlendStyle" => style_entry = Some(Kind::Other), b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(|v| names.text(v)), @@ -549,9 +559,7 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap HashMap { - let name = vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v)); - let matrix = vals.iter().rev().find_map(|v| match v { - V::Arr(a) => { - let n: Vec = a.iter().filter_map(V::num).collect(); - <[f64; 6]>::try_from(n.as_slice()).ok().filter(|m| m.iter().all(|v| v.is_finite())) - } - _ => None, - }); - let controls: Vec = - vals.iter().skip_while(|v| !matches!(v, V::Str(_))).skip(1).take_while(|v| !matches!(v, V::Arr(_))).filter_map(V::num).collect(); - if let Some(name) = name { - f.pattern = Some((name, matrix, controls)); + if let Some(pattern) = pattern_operands(vals, names) { + f.pattern = Some(pattern); } } _ => {} } } +/// The operands of `(name) … [matrix] p`: the pattern's name read by `names`, its matrix (six +/// finite numbers) and the numbers between the name and the matrix. `None` without a name. +fn pattern_operands(vals: &[V], names: &mut Names) -> Option<(String, Option<[f64; 6]>, Vec)> { + let name = vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v))?; + let matrix = vals.iter().rev().find_map(|v| match v { + V::Arr(a) => { + let n: Vec = a.iter().filter_map(V::num).collect(); + <[f64; 6]>::try_from(n.as_slice()).ok().filter(|m| m.iter().all(|v| v.is_finite())) + } + _ => None, + }); + let controls: Vec = + vals.iter().skip_while(|v| !matches!(v, V::Str(_))).skip(1).take_while(|v| !matches!(v, V::Arr(_))).filter_map(V::num).collect(); + Some((name, matrix, controls)) +} + +/// A paint operator in a style being read: one of the last filter's (`filters`), else one of a style +/// that is one paint (`simple`, while one is read). +fn style_paint_op(filters: &mut [Filter], simple: &mut Option>, op: &str, vals: &[V], names: &mut Names) { + if let Some(f) = filters.last_mut() { + paint_op(f, op, vals, names); + } else if let Some(paint) = simple.as_mut() { + simple_paint_op(paint, op, vals, names); + } +} + +/// A paint operator of a style that is one paint: a pattern (`p`) is kept as its +/// [`StyleItem::Pattern`], and any other paint after it (a colour, a stroke's pattern or a +/// gradient) leaves the style without one. +fn simple_paint_op(paint: &mut Option, op: &str, vals: &[V], names: &mut Names) { + match op { + "p" => *paint = pattern_operands(vals, names).map(|(name, matrix, controls)| StyleItem::Pattern { name, matrix, controls }), + "Xa" | "k" | "g" | "x" | "Xx" | "Xk" | "Xs" | "Xz" | "XA" | "K" | "G" | "X" | "XX" | "XK" | "XS" | "XZ" | "xa" | "xx" | "xk" | "xs" + | "xz" | "P" | "Bg" => *paint = None, + _ => {} + } +} + /// A variable-width stroke's profile: its widths (fractions of the stroke's) at positions along the /// path, the same both sides unless it gives each side's. fn width_profile(arrays: &HashMap>) -> Option { diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index d39a87b5c0..ebe161a251 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1496,3 +1496,18 @@ fn art_styles_read_registration_and_named_colors() { let [StyleItem::Stroke { paint: Paint::Solid { swatch, tint, .. }, .. }] = st_stroke.items.as_slice() else { panic!("{st_stroke:?}") }; assert_eq!((swatch.as_deref(), *tint), (Some(vectorcraft_color::swatch::REGISTRATION), 0.0)); } + +/// Only a style that is one paint holds a pattern for type: the same pattern paint in a style that +/// also has filters gives none, whichever comes first. +#[test] +fn only_a_style_of_one_pattern_paint_holds_a_pattern_for_type() { + let simple = "/SimpleStyle :\n0 O\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 1 Xd\n/Paint ;\n"; + let active = "/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n1 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\ + /FillStyle : 0 O\n1 0 0 0 k\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n;\n"; + let styles = |body: &str| art_styles_in(format!("/KnownStyle :\n(S) /Name ,\n{body} /Def ;\n").as_bytes(), &mut Names::default()); + assert!(styles(simple).get("S").is_some_and(|s| s.simple_pattern.is_some())); + for body in [format!("{simple}{active}"), format!("{active}{simple}")] { + assert!(styles(&body).get("S").is_none_or(|s| s.simple_pattern.is_none()), "{body}"); + } +} diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 194c0ff1e2..efaed14199 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -21,7 +21,7 @@ //! side; the canvas's centre, 8191.5, is the centre of the file's `%AI3_TemplateBox`. A story without //! a layout has its point type's anchor at its frame's point. See [`Story::place`]. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen}; use vectorcraft_color::{Color, Paint}; @@ -461,6 +461,11 @@ struct Style { v_scale: f64, fill: Option, stroke: Option, + /// The pattern that paints the fill or the stroke in place of its colour: the one the art + /// style named by key 5 of its paint gives ([`Texts::with_patterns`]), placed in the document. + /// `None` for any other paint, which keeps its colour. + fill_pattern: Option, + stroke_pattern: Option, stroke_width: f64, /// Key 61, the stroke's cap: 0 butt, 1 round, 2 projecting (square). stroke_cap: LineCap, @@ -491,9 +496,23 @@ pub(super) struct Texts { /// attributes (key 5) and the paragraph style it is based on (key 6, an index into these). para_sheets: Vec, frames: Vec, + /// The patterns that paint characters, by the name of the art style that gives each + /// ([`Self::with_patterns`]). + patterns: HashMap, } impl Texts { + /// The text document with its characters painted by `patterns`: the patterns of the editing + /// data's art styles that paint with one, each placed in the document, by the style's name. + /// A run's fill or stroke (key 53 or 54) is a paint whose key 0 is a colour and whose key 5 + /// names the art style that paints it. In users' files (used locally, never committed), such a + /// style is one paint: a colour, which key 0 already has, or a pattern, whose key 0 is black + /// (`/0 0 /1 [ 1 0 ]`). The pattern's matrix is in art space, as a path's pattern fill has it: + /// the files' own pages tile the type with the matrix a path's fill would get there. + pub(super) fn with_patterns(self, patterns: HashMap) -> Texts { + Texts { patterns, ..self } + } + /// The text document in the editing copy `data`, if it has one that can be read. pub(super) fn read(data: &[u8]) -> Option { fn find(h: &[u8], n: &[u8], from: usize) -> Option { @@ -540,6 +559,7 @@ impl Texts { char_sheets: sheets("5"), para_sheets: sheets("6"), frames: doc.get("0")?.get("8")?.get("0")?.list().to_vec(), + patterns: HashMap::new(), }) } @@ -747,6 +767,8 @@ impl Texts { let on = |k: &str| !matches!(pick(k), Some(Val::Bool(false))); let font = num("0").and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.is_empty()).cloned(); let size = num("1").filter(|v| *v > 0.0 && *v < 1e5).unwrap_or(12.0); + // Key 5 of a paint names the art style that paints it ([`Self::with_patterns`]). + let pattern = |k: &str| pick(k).and_then(|p| p.get("5")).and_then(Val::str).and_then(|name| self.patterns.get(name)).cloned(); Style { font, size, @@ -757,6 +779,8 @@ impl Texts { v_scale: num("7").filter(|v| *v > 0.0 && *v < 100.0).unwrap_or(1.0), fill: if on("56") { Some(pick("53").and_then(paint).unwrap_or(Color::BLACK)) } else { None }, stroke: if matches!(pick("57"), Some(Val::Bool(true))) { pick("54").and_then(paint) } else { None }, + fill_pattern: pattern("53"), + stroke_pattern: pattern("54"), stroke_width: num("63").filter(|v| *v >= 0.0 && *v < 1e4).unwrap_or(1.0), // Numbered as PostScript's `setlinecap` and `setlinejoin` operators number them // (PostScript Language Reference, 3rd ed.): in users' files the page strokes type with @@ -1109,6 +1133,10 @@ impl Story { // The text document measures in the art's units too: the document's scale carries its // sizes and spacings onto the page (a Large Canvas copy is written at 1:10; #2031). let scale = scale * art_scale(to_doc); + // Patterns stay where the document has them, wherever the type's text space is (`xf`'s + // determinant is 1, so it can be inverted; a pattern that still doesn't map to finite + // numbers keeps its colour, see `char_style`). + let to_text = xf.inverse(); let chars: Vec = text.chars().collect(); let mut runs = vec![]; let mut from = 0usize; @@ -1119,13 +1147,13 @@ impl Story { if piece.is_empty() { continue; } - runs.push(TextRun::new(piece, char_style(st, scale))); + runs.push(TextRun::new(piece, char_style(st, scale, to_text))); } if from < chars.len() { let last = self.runs.last().map(|r| &r.1)?; - runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::(), char_style(last, scale))); + runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::(), char_style(last, scale, to_text))); } - let first = self.runs.first().map(|r| char_style(&r.1, scale))?; + let first = self.runs.first().map(|r| char_style(&r.1, scale, to_text))?; let mut t = TextObject::point(Point::ZERO, "", first); if !runs.is_empty() { t.runs = runs; @@ -1295,10 +1323,20 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle { } /// The character style of character attributes `s`: its font from the fonts the app has, its -/// lengths (size, leading, baseline shift, stroke width) scaled by `scale`. -fn char_style(s: &Style, scale: f64) -> CharStyle { +/// lengths (size, leading, baseline shift, stroke width) scaled by `scale`, and its patterns placed +/// in the type's text space by `to_text` (the document → its text space), where they stay as the +/// document has them. +fn char_style(s: &Style, scale: f64, to_text: Affine) -> CharStyle { let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style); let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); + // A fill or stroke that is on takes its pattern in place of its colour. + let paint = |color: Option, pattern: &Option| match (color, pattern) { + // A pattern that can't be placed (the type's text space out of range) keeps the colour. + (Some(_), Some(Paint::Pattern { pattern, xf })) if (to_text * *xf).is_finite() => { + Paint::Pattern { pattern: pattern.clone(), xf: to_text * *xf } + } + (color, _) => color.map_or(Paint::None, Paint::solid), + }; // Type without a stroke keeps the stroke's defaults, as its weight is 0. let (stroke_cap, stroke_join, stroke_miter_limit) = if s.stroke.is_some() { (s.stroke_cap, s.stroke_join, s.stroke_miter_limit) } else { (LineCap::Butt, LineJoin::Miter, 10.0) }; @@ -1314,8 +1352,8 @@ fn char_style(s: &Style, scale: f64) -> CharStyle { baseline_shift: s.baseline_shift * scale, h_scale: s.h_scale * 100.0, v_scale: s.v_scale * 100.0, - fill: s.fill.map_or(Paint::None, Paint::solid), - stroke: s.stroke.map_or(Paint::None, Paint::solid), + fill: paint(s.fill, &s.fill_pattern), + stroke: paint(s.stroke, &s.stroke_pattern), stroke_width: if s.stroke.is_some() { s.stroke_width * scale } else { 0.0 }, stroke_cap, stroke_join, @@ -1350,6 +1388,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let justify = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justify; // Right to left from the document's defaults, or the paragraph's own. @@ -1373,6 +1412,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let auto = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justification.auto_leading; assert_eq!(auto(b""), JustificationOptions::default().auto_leading); @@ -1395,6 +1435,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let options = |own: &[u8], scale: f64| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale).hyphenation; let want = HyphenationOptions { words_longer_than: 6, after_first: 2, before_last: 2, hyphen_limit: 0, zone: 36.0, capitalized: true }; @@ -1412,6 +1453,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let own = |own: &[u8]| para_style(&bare.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).hyphenation; assert_eq!(own(b""), d); @@ -1431,6 +1473,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let tabs = |own: &[u8], scale: f64| { para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale) @@ -1497,6 +1540,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), } .para(None); let mut s = story(" 80 mm ", &[0.0], ¶); @@ -1544,6 +1588,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: cases.iter().map(|c| frame(c)).collect(), + patterns: HashMap::new(), }; let vertical: Vec = (0..cases.len() + 1).map(|i| texts.frame_vertical(i)).collect(); assert_eq!(vertical, [true, false, false, false, false, false, false, false, false, false, false]); @@ -1561,6 +1606,7 @@ mod tests { char_sheets: vec![read(b"/0 ([Normal Character Style]) /6 << >>").unwrap(), read(b"/0 (Block) /6 << /35 3 >>").unwrap()], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let tcy = |run: &[u8]| texts.style(Some(&read(run).unwrap())).tate_chu_yoko; assert!(tcy(b"/6 << /35 3 >>"), "the run's own"); @@ -1584,6 +1630,7 @@ mod tests { char_sheets: vec![read(b"/0 ([Normal Character Style]) /6 << >>").unwrap(), read(b"/0 (Rotated) /6 << /35 2 >>").unwrap()], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let roman = |defaults: &[u8], run: &[u8]| texts(defaults).style(Some(&read(run).unwrap())).standard_vertical_roman; assert!(roman(b"/35 1", b"/6 << >>"), "the document's defaults"); @@ -1614,9 +1661,10 @@ mod tests { char_sheets: vec![read(b"/0 ([Normal Character Style]) /6 << >>").unwrap(), read(b"/0 (Rounded) /6 << /61 1 /62 1 >>").unwrap()], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let stroke = |run: &[u8]| { - let s = char_style(&texts.style(Some(&read(run).unwrap())), 1.0); + let s = char_style(&texts.style(Some(&read(run).unwrap())), 1.0, Affine::IDENTITY); (s.stroke_cap, s.stroke_join, s.stroke_miter_limit) }; assert_eq!(stroke(b"/6 << /61 2 /62 2 /64 2.5 >>"), (LineCap::Square, LineJoin::Bevel, 2.5), "the run's own"); @@ -1638,6 +1686,88 @@ mod tests { assert_eq!(stroke(b"/5 1 /6 << /57 false >>"), (LineCap::Butt, LineJoin::Miter, 10.0), "no stroke"); } + /// The pattern "Dots", its tile's corner at `at`. + fn dots_at(at: (f64, f64)) -> Paint { + Paint::Pattern { pattern: "Dots".into(), xf: Affine::translate(at) } + } + + /// Texts whose characters may name the art style "Anon 1", which paints with "Dots" at (10, 20) + /// in the document, and "Solid", which isn't a pattern. + fn patterned_texts() -> Texts { + let patterns = HashMap::from([("Anon 1".to_string(), dots_at((10.0, 20.0))), ("Solid".to_string(), Paint::solid(Color::WHITE))]); + Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![], + para_sheets: vec![], + frames: vec![], + patterns: HashMap::new(), + } + .with_patterns(patterns) + } + + /// Key 5 of a run's fill or stroke names the art style that paints it: one that paints with a + /// pattern gives the run that pattern in place of the colour of key 0 (black), placed in the + /// type's text space where the document has it. A fill or stroke that is off stays off, and a + /// key 5 that isn't the name of such a style, or isn't a name at all, keeps the colour. + #[test] + fn a_run_painted_by_a_patterns_art_style_takes_the_pattern() { + // GUARDS: type a pattern paints came in black, the colour key 0 keeps for it. + let texts = patterned_texts(); + let black_paint = Paint::solid(Color::gray(1.0)); + let to_text = Affine::translate((-4.0, -5.0)); + let paints = |run: &str| { + let s = char_style(&texts.style(Some(&read(run.as_bytes()).unwrap())), 1.0, to_text); + (s.fill, s.stroke) + }; + let black = |style: &str| format!("<< /99 /CAITextPaint /0 << /0 0 /1 [ 1.0 0.0 ] >> {style} >>"); + let fill = |style: &str| paints(&format!("/6 << /53 {} >>", black(style))); + assert_eq!(fill("/5 (Anon 1)"), (dots_at((6.0, 15.0)), Paint::None)); + let stroke = paints(&format!("/6 << /53 {} /57 true /54 {} >>", black(""), black("/5 (Anon 1)"))); + assert_eq!(stroke, (black_paint.clone(), dots_at((6.0, 15.0)))); + // Off. + assert_eq!(paints(&format!("/6 << /56 false /53 {} >>", black("/5 (Anon 1)"))), (Paint::None, Paint::None)); + assert_eq!(paints(&format!("/6 << /57 false /54 {} >>", black("/5 (Anon 1)"))).1, Paint::None); + // A stroke whose colour can't be read paints nothing, pattern or not, as before. + assert_eq!(paints("/6 << /57 true /54 << /0 << /0 9 /1 [ 1.0 ] >> /5 (Anon 1) >> >>").1, Paint::None); + for other in ["", "/5 (Anon 2)", "/5 (Solid)", "/5 ()", "/5 /Anon", "/5 1", "/5 [ (Anon 1) ]", "/5 << /0 (Anon 1) >>", "/5 true"] { + assert_eq!(fill(other), (black_paint.clone(), Paint::None), "{other}"); + } + // A text space the pattern can't be placed in keeps the colour. + let run = read(format!("/6 << /53 {} >>", black("/5 (Anon 1)")).as_bytes()).unwrap(); + for to_text in [Affine::new([1e308, 0.0, 0.0, 1e308, 0.0, 0.0]), Affine::new([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0])] { + assert_eq!(char_style(&texts.style(Some(&run)), 1.0, to_text).fill, black_paint.clone(), "{to_text:?}"); + } + } + + /// A run's pattern stays where the document has it whatever the type's own text space: turned + /// and moved point type, and area type, whose text space is the document's. + #[test] + fn a_runs_pattern_stays_where_the_document_has_it() { + let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 100.0]); + for frame in [ + FrameKind::Point([0.0, 1.0, -1.0, 0.0, 30.0, 40.0]), + FrameKind::Point([2.0, 0.0, 0.0, 2.0, -7.0, 3.0]), + FrameKind::Area(area_segments(), AreaFrame::default()), + ] { + let mut st = story("type", &[0.0], &Para::default()); + st.frames = vec![frame.clone()]; + st.runs[0].1.fill_pattern = Some(dots_at((10.0, 20.0))); + st.runs[0].1.stroke = Some(Color::BLACK); + st.runs[0].1.stroke_pattern = Some(dots_at((-3.0, 8.0))); + let node = st.node(NodeId(1), 0, (0.0, 0.0), to_doc).unwrap(); + let NodeKind::Text(t) = node.kind else { panic!("not type") }; + for (paint, at) in [(&t.runs[0].style.fill, Point::new(10.0, 20.0)), (&t.runs[0].style.stroke, Point::new(-3.0, 8.0))] { + let Paint::Pattern { pattern, xf } = paint else { panic!("{paint:?}") }; + assert_eq!(pattern, "Dots"); + let corner = t.xf * *xf * Point::ZERO; + assert!((corner - at).hypot() < 1e-9, "{frame:?}: {corner:?}"); + } + } + } + /// A right-to-left paragraph is laid out right to left even when it has no right-to-left /// letters (a dimension's label " 120.5 mm " reads "mm 120.5"); a left-to-right one, or one /// that doesn't say, goes by its letters. @@ -1651,6 +1781,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![], + patterns: HashMap::new(), }; let direction = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).direction; assert_eq!(direction(b"/0 2"), Some(ParaDirection::RightToLeft), "from the document's defaults"); @@ -1698,6 +1829,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![read(doc.as_bytes()).unwrap()], + patterns: HashMap::new(), }; texts.frame(0) } @@ -1904,6 +2036,8 @@ mod tests { v_scale: 1.0, fill: Some(Color::BLACK), stroke: None, + fill_pattern: None, + stroke_pattern: None, stroke_width: 1.0, stroke_cap: LineCap::Butt, stroke_join: LineJoin::Miter, @@ -2352,6 +2486,7 @@ mod tests { char_sheets: vec![], para_sheets: vec![], frames: vec![read(doc.as_bytes()).unwrap()], + patterns: HashMap::new(), }; texts.frame(0).unwrap() } diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index c86cdb9516..978591b2d9 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -217,6 +217,7 @@ pub(super) fn build_from( placed_pages, envelopes_drawn, styles_read, + text_patterns, canvas_scale, xml_ids, bindings, @@ -374,7 +375,7 @@ pub(super) fn build_from( // The page's type that needs no text object of the layers: the layers' own legacy text, or // placed files that show it. let as_outlines = page_type_elsewhere(infos.is_empty(), infos.iter().all(|i| !i.shown), &mut shown, &mut hidden, &doc, &placed_elsewhere)?; - let texts = Texts::read(data); + let texts = Texts::read(data).map(|t| t.with_patterns(text_patterns)); // Each story is read once, however many text objects name it. let mut read: std::collections::BTreeMap>> = Default::default(); let stories: Vec>> = infos diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 8f64280367..23e8f0f70a 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -3366,6 +3366,71 @@ fn stroked_type_keeps_its_cap_join_and_miter_limit() { ); } +#[test] +fn type_painted_with_a_pattern_takes_it_from_the_art_style_its_paint_names() { + // GUARDS: type filled or stroked with a pattern swatch came in black: the text document keeps + // black as the paint's colour, and the pattern is in the art style that key 5 of the paint + // names, a style of one paint (`/SimpleStyle`) whose matrix is in art space. + use vectorcraft_color::{Color, Paint}; + use vectorcraft_geom::Point; + let dots = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n"; + let simple = |name: &str, paint: &str| { + format!( + "/KnownStyle :\n({name}) /Name ,\n/SimpleStyle :\n0 O\n{paint}\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 1 Xd\n/Paint ;\n /Def ;\n" + ) + }; + let pattern = |m: &str| format!("(Dots) 0 0 1 1 0 0 0 0 0 [{m}] p"); + let styles = [ + simple("Anon 1", &pattern("1 0 0 1 0 0")), + simple("Anon 2", &pattern("2 0 0 2 10 20")), + // Styles that give no pattern: one the file doesn't define, a matrix that doesn't place, no + // name, a colour after the pattern, a colour, a gradient. + simple("Anon 3", "(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p"), + simple("Anon 4", &pattern("0 0 0 0 0 0")), + simple("Anon 5", "0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p"), + simple("Anon 6", &format!("{}\n0 0 0 1 k", pattern("1 0 0 1 0 0"))), + simple("Anon 7", "0 0 0 1 k"), + simple("Anon 8", "0 Bb\n2 (Rainbow) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB"), + ] + .concat(); + let styles = format!("%AI9_BeginArtStyles\n{styles}%AI9_EndArtStyles\n"); + let doc = text_document(&[("abcdefghijklmnopq\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let one = "<< /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 18 >>"; + assert!(doc.contains(one)); + let black = |style: &str| format!("<< /99 /CAITextPaint /0 << /0 0 /1 [ 1.0 0.0 ] >> /5 ({style}) >>"); + let run = |own: &str| format!("<< /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 {own} >> >> >> /1 2 >>"); + let mut runs = vec![run(&format!("/53 {}", black("Anon 1"))), run(&format!("/53 {} /57 true /54 {} /63 2.0", black("Anon 7"), black("Anon 2")))]; + runs.extend((3..=8).map(|n| run(&format!("/53 {}", black(&format!("Anon {n}")))))); + // A style the file doesn't have, over "q" and the story's end. + runs.push(run(&format!("/53 {}", black("Anon 9")))); + let doc = doc.replacen(one, &runs.join(" "), 1); + let art = native_with_text(&format!("{dots}{styles}{}{}", layer("Back", true, &square(10, 10)), layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{:?}", r.warnings); + let t = texts[0]; + // Each character's fill and stroke, a pattern as where its tile's top left corner is. + let paint = |p: &Paint| match p { + Paint::Pattern { pattern, xf } => { + let at = t.xf * *xf * Point::ZERO; + format!("{pattern} at {:.3} {:.3}", at.x, at.y) + } + Paint::Solid { color, .. } if *color == Color::gray(1.0) => "black".into(), + Paint::None => "none".into(), + other => format!("{other:?}"), + }; + let chars: Vec<(char, String, String)> = + t.runs.iter().flat_map(|r| r.text.chars().map(|c| (c, paint(&r.style.fill), paint(&r.style.stroke))).collect::>()).collect(); + let want = |c: char, fill: &str, stroke: &str| (c, fill.to_string(), stroke.to_string()); + // The tile's top left (0, 4) in art space, on a page 100 high: (0, 96). Scaled twice and moved + // by (10, 20): (10, 28) in art space, (10, 72) in the document. + let mut expected = vec![want('a', "Dots at 0.000 96.000", "none"), want('b', "Dots at 0.000 96.000", "none")]; + expected.extend(['c', 'd'].map(|c| want(c, "black", "Dots at 10.000 72.000"))); + expected.extend("efghijklmnopq".chars().map(|c| want(c, "black", "none"))); + assert_eq!(chars, expected); + assert_eq!(r.document.pattern("Dots").map(|p| p.tile.height()), Some(4.0)); +} + #[test] fn orientation_and_tate_chu_yoko_values_it_cant_use_are_ignored() { for bad in ["(2)", "2.5", "1e300", "-2", "1", "true", "[ 2 ]"] { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e2721d431b..09c34153f7 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (`.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -54,7 +54,10 @@ private editing data. VectorCraft: own paints with the paint before the mask's dictionary, as the writer leaves out paint that doesn't change. Type from the text document keeps auto leading as auto, each line taking the paragraph's share (Auto Leading) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)), - and the paragraph's hyphenation options and tab stops. Stroked type keeps its stroke's cap, join and miter limit. Area type of one + and the paragraph's hyphenation options and tab stops. Stroked type keeps its stroke's cap, join and miter limit. Type filled or + stroked with a pattern keeps its pattern, placed in the document as a path's pattern fill would be: a character's paint names + an art style of one paint, and the pattern is in that style (its colour in the text document is black). A pattern the file + doesn't define, or a style that isn't a pattern, keeps the colour as before. Area type of one frame whose last lines the file sets but which don't fit here (the fallback font, or a descent past the frame's bottom) may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines, with a warning when it does. RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb` @@ -340,6 +343,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern. In the text document a character's fill or stroke is a colour (key 0) and the name of an art style (key 5). In users' files (used locally, never committed) that style is one paint (`/SimpleStyle`): a colour, the one key 0 already has, or a pattern, `(name) … [matrix] p`, whose key 0 is black. Such type came in black, or stroked in black. It now takes the pattern, its matrix in art space as a path's pattern fill has it (the files' PDF parts tile such type with that matrix), placed in the type's text space so that it stays where the document has it. A pattern the file doesn't define or can't place, and a style of any other paint, keep the colour as before. A `.ai` saved again writes such type as its outlines, as it writes all type with pattern paint, with that warning. On 224 of our own files: envelopes of type that open live go from 154 in 45 files to 157 in 46, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 10 of the 13 files with patterned type, 8 closer to the file's own page (one by under 0.01 points, in a file whose render differs from its page almost everywhere either way), 1 further by 0.03 points of its pixels (an envelope of type there now opens live in place of its drawn art), 1 not compared (its page renders at another size); notes change in 3 files: one loses its type note, one loses the note for type in an envelope and, now compared with its page, gains its type notes, and one that opens from its PDF part names a smaller difference. Tests `ate::tests::a_run_painted_by_a_patterns_art_style_takes_the_pattern`, `a_runs_pattern_stays_where_the_document_has_it`, `tests_native::type_painted_with_a_pattern_takes_it_from_the_art_style_its_paint_names`. Scores and estimates unchanged. (#6856) | | 2026-11-11 | minor | `.ai` and EPS import, and what the canvas, every export and `.ai` save draw: type's strokes in an envelope (its characters' and those on its own appearance) paint along the bent glyph outlines at their own weight, Distort Appearance on or off, as the drawn art of users' `.ai` files strokes them (used locally, never committed: there the drawn art keeps those strokes as stroked paths at the type's own weights, and its ring is as wide wherever the grid squeezes or stretches the type). With Distort Appearance, VectorCraft bent them with the art, as their filled outlines, so an envelope of stroked type in a squeezed or stretched grid didn't draw as its drawn art and kept it. The same holds in an envelope inside an envelope. Paths' strokes, geometry effects, opacity masks, linear gradients and pattern fills bend as before. On 224 of our own files: envelopes of type that open live go from 135 in 37 files to 154 in 45, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 31 files, by at most 2.3% of their pixels, 19 closer to the file's own page and 12 further, 11 of those where an envelope of type now opens live in place of its drawn art (the page's own art) and one by 4 pixels. Tests `envelope_distort::type_strokes_paint_at_their_own_weight_in_a_squeezed_envelope`, `a_stroke_on_types_appearance_paints_at_its_own_weight_in_a_squeezed_envelope`, `a_paths_stroke_still_bends_in_a_squeezed_envelope`, `tests_native::an_envelope_of_stroked_type_whose_drawn_art_strokes_the_bent_outlines_at_the_weight_opens_live` (#6644) | | 2026-11-11 | minor | `.ai` and EPS import, and what SVG export, PDF export and `.ai` save draw: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as a `.ai`'s own page aligns them (see type-parity.md, same date). In 224 users' files (used locally, never committed), 63 render differently: 54 closer to the page, 7 further, 1 alike and 1 that couldn't be compared; among them the median share of pixels that differ from the page went from 4.4% to 1.0%. Of the 7: one now opens from its layers instead of its PDF part (they differ from the page by less than the import's 5% limit now; type note 3.8%); one has a centred line whose spacing inside it differs from the page's, which now centres on the page's line while half of it no longer lies on the page's glyphs (type note 1.4% to 2.4%); five moved by less than 0.05 of a percentage point, in type the page draws in other glyphs. The type note shows a smaller share in 6 files; 2 more envelopes of type open live (129 to 131, none fewer in any file). SVG import: `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, so a centred or right-aligned letter-spaced line moves back by half or all of it and stays where a reader draws it; SVG export, which anchors lines so, reads back unchanged (test `import_text::anchored_letter_spaced_lines_stay_where_a_reader_draws_them`). Affinity import: centred or right-aligned tracked artistic text moves by half or all of its last character's tracking, as layout now aligns it (unverified against Affinity, whose files we don't have), and differently aligned paragraphs line up by the widths layout aligns them by (`affinity_features::artistic_text_aligns_its_lines_within_the_longest`). Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) | From 0a3abb8534bf7768b766f2356ce324a14bf1efec Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:00:15 -0800 Subject: [PATCH 05/34] Stroke: custom arrowheads cap subpaths, file size and preview size - A custom arrowhead path allows at most 4,096 subpaths; stroke.set refuses a longer one and reading drops it. - A symbol whose shapes cannot be united or clipped is listed in skipped instead of becoming a head that misses part of its art. - The arrowheads file is refused when its document unpacks to more than 64 MB. - Edit Arrowheads creates the starter file only when none exists and otherwise opens the file unchanged. - After a save to the arrowheads file, the heads are read back from the written file. - The plug-in argument check accepts path under startArrow.Custom and endArrow.Custom in stroke.set. - A custom head's menu preview is scaled to fit its box, and rows outside the visible part of the menu do not render previews. --- crates/doc/src/appearance/stroke_style.rs | 14 ++-- crates/doc/src/appearance/tests.rs | 7 ++ crates/engine/src/cmd/arrowheads.rs | 53 ++++++++++----- crates/engine/src/cmd/fileio/save.rs | 6 +- crates/engine/src/cmd/plugin_call.rs | 40 ++++++++++-- crates/engine/src/cmd/stroke.rs | 4 +- crates/engine/src/tests/arrowheads.rs | 65 +++++++++++++++++++ crates/format/src/lib.rs | 38 ++++++++++- crates/format/src/unpack.rs | 54 +++++++++++---- crates/ui-egui/src/panels/stroke.rs | 43 +++++++++--- .../ui-egui/src/panels/tests_strokedepth.rs | 37 +++++++++++ docs/mcp.md | 11 ++-- 12 files changed, 310 insertions(+), 62 deletions(-) diff --git a/crates/doc/src/appearance/stroke_style.rs b/crates/doc/src/appearance/stroke_style.rs index e029f8bc27..18b226c676 100644 --- a/crates/doc/src/appearance/stroke_style.rs +++ b/crates/doc/src/appearance/stroke_style.rs @@ -84,24 +84,26 @@ impl From for CustomArrowhead { } impl CustomArrowhead { - /// The most anchors a custom head's outline may have. + /// The most anchors a custom head's outline may have, and the most subpaths. pub const MAX_ANCHORS: usize = 4096; /// How far from the tip a custom head's outline may reach, in head units. pub const MAX_UNITS: f64 = 64.0; /// Head `name` with outline `path` (in head units). An outline with more than - /// [`Self::MAX_ANCHORS`] anchors, a point that is not finite, or a point farther than - /// [`Self::MAX_UNITS`] from the tip is dropped: the head then draws nothing. + /// [`Self::MAX_ANCHORS`] anchors or subpaths, a point that is not finite, or a point farther + /// than [`Self::MAX_UNITS`] from the tip is dropped: the head then draws nothing. pub fn new(name: String, path: PathData) -> Self { let path = if Self::fits(&path) { path } else { PathData::default() }; CustomArrowhead { name, path } } - /// Whether outline `path` is within [`Self::MAX_ANCHORS`] and [`Self::MAX_UNITS`], with finite - /// points only. + /// Whether outline `path` has at most [`Self::MAX_ANCHORS`] anchors and as many subpaths, with + /// finite points within [`Self::MAX_UNITS`] only. pub fn fits(path: &PathData) -> bool { let anchors = path.subpaths.iter().map(|s| s.anchors.len()).sum::(); let near = |p: vectorcraft_geom::Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= Self::MAX_UNITS && p.y.abs() <= Self::MAX_UNITS; - anchors <= Self::MAX_ANCHORS && path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| near(a.p) && near(a.h_in) && near(a.h_out)) + anchors <= Self::MAX_ANCHORS + && path.subpaths.len() <= Self::MAX_ANCHORS + && path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| near(a.p) && near(a.h_in) && near(a.h_out)) } fn bounds(&self) -> Option { self.path.control_bounds() diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index c814195596..22e5659051 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -387,6 +387,13 @@ fn custom_arrowheads_out_of_bounds_draw_nothing() { let anchors = vec![vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(1.0, 0.0)); CustomArrowhead::MAX_ANCHORS + 1]; let many = CustomArrowhead::new("Many".into(), vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::new(anchors, true))); assert!(many.path.subpaths.is_empty(), "too many anchors"); + // Empty subpaths add no anchors, and still count. + let mut hollow = c.path.clone(); + hollow.subpaths.extend(std::iter::repeat_n(vectorcraft_geom::SubPath::new(vec![], false), CustomArrowhead::MAX_ANCHORS)); + assert!(!CustomArrowhead::fits(&hollow), "too many subpaths"); + let json = serde_json::to_value(CustomArrowhead { name: "Hollow".into(), path: hollow }).unwrap(); + let hollow: CustomArrowhead = serde_json::from_value(json).unwrap(); + assert!(hollow.path.subpaths.is_empty(), "too many subpaths, read back"); } #[test] diff --git a/crates/engine/src/cmd/arrowheads.rs b/crates/engine/src/cmd/arrowheads.rs index 8e2eee865f..4dc58f6c8e 100644 --- a/crates/engine/src/cmd/arrowheads.rs +++ b/crates/engine/src/cmd/arrowheads.rs @@ -22,7 +22,8 @@ use super::{CommandSpec, Result, Session, always, bad, cmd}; /// The arrowheads file's name, in the folder of the preferences. pub const FILE_NAME: &str = "Arrowheads.vectorcraft"; -/// The largest arrowheads file read. +/// The largest arrowheads file read, and the most its document or its images may unpack to when +/// compressed. pub const MAX_FILE_BYTES: u64 = 64 << 20; /// The most heads read from the file; symbols past it are skipped. pub const MAX_HEADS: usize = 256; @@ -121,6 +122,7 @@ pub fn heads_of(doc: &Document) -> Read { /// Symbol `sym` of `d` as a head. fn head(d: &mut Document, brushes: &[vectorcraft_brush::Brush], sym: &Symbol) -> std::result::Result { const TOO_DETAILED: &str = "its art is too long or too detailed for a head"; + const UNITED: &str = "its shapes could not be combined into one"; if art_anchors(&sym.art) > MAX_ART_ANCHORS { return Err(TOO_DETAILED); } @@ -130,7 +132,10 @@ fn head(d: &mut Document, brushes: &[vectorcraft_brush::Brush], sym: &Symbol) -> if shapes.too_detailed { return Err(TOO_DETAILED); } - let area = shapes.united(); + if shapes.failed { + return Err(UNITED); + } + let area = shapes.united().ok_or(UNITED)?; let b = area.bounds().filter(|b| b.height() > 1e-9 && b.is_finite()).ok_or("its art paints nothing")?; let tip = b.center() + d.symbol_registration(&sym.name).offset_from_center(b.width(), b.height()); // Head units: x back from the tip, y across to the right of +x, 4 units to the art's height. @@ -172,6 +177,8 @@ struct Shapes { anchors: usize, /// More anchors were asked for than fit; the shapes past the limit were not made. too_detailed: bool, + /// A clip group's art could not be combined or clipped, so a shape it paints is missing. + failed: bool, } impl Shapes { @@ -186,9 +193,10 @@ impl Shapes { self.list.push(shape); } } - fn united(&self) -> PathData { + /// The shapes united, or `None` when one of them could not be (the union would miss it). + fn united(&self) -> Option { let refs: Vec<(&PathData, FillRule)> = self.list.iter().map(|(p, r)| (p, *r)).collect(); - vectorcraft_pathops::unite_all(&refs) + vectorcraft_pathops::try_unite_all(&refs).ok() } /// The shapes of `inside` clipped to `outline` (filled under `rule`), added as one. fn push_clipped(&mut self, inside: Shapes, outline: &PathData, rule: FillRule) { @@ -196,7 +204,14 @@ impl Shapes { self.too_detailed = true; return; } - let clipped = vectorcraft_pathops::boolean(&inside.united(), FillRule::NonZero, outline, rule, BoolOp::Intersect); + self.failed |= inside.failed; + let clip = |art: PathData| { + vectorcraft_pathops::try_boolean(&art, FillRule::NonZero, outline, rule, BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION).ok() + }; + let Some(clipped) = inside.united().and_then(clip) else { + self.failed = true; + return; + }; if !clipped.subpaths.is_empty() { self.push((clipped, FillRule::NonZero)); } @@ -345,11 +360,11 @@ impl Session { } } - /// Document `doc` was saved to `path`: when that is the arrowheads file, its heads are the - /// ones listed now. - pub(crate) fn arrowheads_saved(&mut self, path: &str, doc: &Document) { + /// A document was saved to `path`: when that is the arrowheads file, its heads are read back + /// from what was written. + pub(crate) fn arrowheads_saved(&mut self, path: &str) { if self.arrowheads.path.as_deref().is_some_and(|a| same_file(a, path)) { - self.arrowheads.set(heads_of(doc)); + self.reload_arrowheads(); } } } @@ -371,7 +386,9 @@ fn read(path: &str) -> std::result::Result, String> { return Err(format!("{path}: larger than {} MB", MAX_FILE_BYTES >> 20)); } let bytes = crate::file_access::unconfined(|| super::fileio::read_file(path)).map_err(|e| e.to_string())?; - let parse = || super::fileio::native_file(&bytes).map(|(doc, ..)| doc).map_err(|e| e.to_string()); + // A compressed file's document, and its images, may unpack to no more than an uncompressed + // file may hold. + let parse = || vectorcraft_format::load_file_capped(&bytes, MAX_FILE_BYTES).map(|f| f.doc).map_err(|e| format!("{path}: {e}")); crate::guard::catch_panic(parse).map_err(|_| format!("{path}: could not be read"))?.map(Some) } @@ -400,13 +417,17 @@ fn reload(s: &mut Session, _: &Value) -> Result { fn edit(s: &mut Session, _: &Value) -> Result { const C: &str = "stroke.arrowheads.edit"; let path = s.arrowheads.path.clone().ok_or_else(|| bad(C, "no arrowheads file here"))?; - let created = !std::path::Path::new(&path).exists(); + if let Some(dir) = std::path::Path::new(&path).parent() { + std::fs::create_dir_all(dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?; + } + // Made only when no file is there, also when another run makes one at the same moment. + let bytes = vectorcraft_format::save_file(&starter()); + let created = match crate::file_access::unconfined(|| super::fileio::write_new_file(&path, &bytes)) { + Ok(()) => true, + Err(_) if std::path::Path::new(&path).exists() => false, + Err(e) => return Err(bad(C, e.to_string())), + }; if created { - if let Some(dir) = std::path::Path::new(&path).parent() { - std::fs::create_dir_all(dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?; - } - let bytes = vectorcraft_format::save_file(&starter()); - crate::file_access::unconfined(|| super::fileio::write_file(&path, &bytes)).map_err(|e| bad(C, e.to_string()))?; s.reload_arrowheads(); } // Already open: that document comes to the front. diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 6e814569a0..713feb82ae 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -1006,7 +1006,7 @@ impl SaveJob { // The other open documents that place this one show the new version. let written = self.plan.path.clone().filter(|_| matches!(self.plan.format.id, "vectorcraft" | "template")); // A save of the arrowheads file lists its heads in the arrowhead menus. - let native = (self.plan.format.id == "vectorcraft").then(|| self.snapshot.clone()); + let native = self.plan.format.id == "vectorcraft"; if let Some(st) = s.document_mut(uid) { self.finish(st); } @@ -1014,8 +1014,8 @@ impl SaveJob { crate::cmd::recovery::forget(s, uid); } if let Some(path) = written { - if let Some(doc) = native { - s.arrowheads_saved(&path, &doc); + if native { + s.arrowheads_saved(&path); } crate::cmd::links::refresh_placed(s, uid, &path); } diff --git a/crates/engine/src/cmd/plugin_call.rs b/crates/engine/src/cmd/plugin_call.rs index b151adcea2..b94a791240 100644 --- a/crates/engine/src/cmd/plugin_call.rs +++ b/crates/engine/src/cmd/plugin_call.rs @@ -121,6 +121,10 @@ pub(crate) const NOT_FILES: &[(&str, &str)] = &[ ("addon.cleanup.rejoin", "paths"), ]; +/// Commands and the keys from the top of their params down to a key in [`FILE_KEYS`] that, there, +/// names part of the document, not a file: a custom arrowhead's outline in `stroke.set`. +const NESTED_NOT_FILES: &[(&str, &[&str])] = &[("stroke.set", &["startArrow", "Custom", "path"]), ("stroke.set", &["endArrow", "Custom", "path"])]; + /// Is command id `id` one a plug-in may never call, from its id alone ([`DENIED_PREFIXES`], /// [`FILE_WORDS`])? fn denied_id(id: &str) -> bool { @@ -184,20 +188,32 @@ pub(crate) fn names_key(text: &str, key: &str) -> bool { /// Does `args`, the arguments of a call of command `id`, carry a key in [`FILE_KEYS`] at any depth /// (in objects and in arrays), such as an effect's `textureImage.path`? Only a key [`NOT_FILES`] -/// names for `id`, at the top level, isn't counted. The walk keeps its own stack, so deep -/// arguments can't overflow the thread's. +/// names for `id`, at the top level, and one at the end of a [`NESTED_NOT_FILES`] key list for +/// `id`, aren't counted. The walk keeps its own stack, so deep arguments can't overflow the +/// thread's. fn names_file(id: &str, args: &Value) -> bool { - let mut stack = vec![(args, true)]; - while let Some((v, top)) = stack.pop() { + // Each value, whether it is the params themselves, and the keys of a NESTED_NOT_FILES list + // still to follow below it. + let mut stack: Vec<(&Value, bool, Option<&[&str]>)> = vec![(args, true, None)]; + while let Some((v, top, trail)) = stack.pop() { match v { - Value::Array(a) => stack.extend(a.iter().map(|x| (x, false))), + Value::Array(a) => stack.extend(a.iter().map(|x| (x, false, None))), Value::Object(o) => { for (k, x) in o { let k = k.as_str(); - if FILE_KEYS.contains(&k) && !(top && NOT_FILES.contains(&(id, k))) { + let rest = if top { + NESTED_NOT_FILES + .iter() + .find(|(c, keys)| *c == id && keys.first() == Some(&k)) + .map(|(_, keys)| keys.get(1..).unwrap_or_default()) + } else { + trail.filter(|keys| keys.first() == Some(&k)).map(|keys| keys.get(1..).unwrap_or_default()) + }; + let document = (top && NOT_FILES.contains(&(id, k))) || rest.is_some_and(<[&str]>::is_empty); + if FILE_KEYS.contains(&k) && !document { return true; } - stack.push((x, false)); + stack.push((x, false, rest.filter(|keys| !keys.is_empty()))); } } _ => {} @@ -536,6 +552,16 @@ mod tests { assert!(!names_file("object.setLiveShape", &json!({"path": [[0, 0], [1, 1]]}))); assert!(names_file("object.setLiveShape", &json!({"options": {"path": "/tmp/x"}}))); assert!(names_file("object.move", &json!({"path": [[0, 0]]}))); + // A custom arrowhead's outline in stroke.set, only where stroke.set takes one. + let outline = json!({"subpaths": []}); + assert!(!names_file("stroke.set", &json!({"endArrow": {"Custom": {"name": "A", "path": outline}}}))); + assert!(!names_file("stroke.set", &json!({"startArrow": {"Custom": {"name": "A", "path": outline}}, "endArrow": "Triangle"}))); + assert!(names_file("stroke.set", &json!({"endArrow": {"Custom": {"name": "A", "path": outline, "file": "/tmp/x"}}}))); + assert!(names_file("stroke.set", &json!({"endArrow": {"Custom": {"path": {"path": "/tmp/x"}}}}))); + assert!(names_file("stroke.set", &json!({"endArrow": {"path": "/tmp/x"}}))); + assert!(names_file("stroke.set", &json!({"options": {"endArrow": {"Custom": {"path": "/tmp/x"}}}}))); + assert!(names_file("stroke.set", &json!({"endArrow": [{"Custom": {"path": "/tmp/x"}}]}))); + assert!(names_file("object.move", &json!({"endArrow": {"Custom": {"path": "/tmp/x"}}}))); // Deep nesting is walked without recursion. let mut deep = json!({"file": "/tmp/x"}); for _ in 0..10_000 { diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 40b2a7aa86..aba92ea200 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", has_doc, stroke_set ), @@ -245,7 +245,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { return Err(bad( C, format!( - "{k}: a custom arrowhead's path needs an area to fill and at most {most} anchors, each within {far} head units of its tip" + "{k}: a custom arrowhead's path needs an area to fill and at most {most} anchors and {most} subpaths, each anchor within {far} head units of its tip" ), )); } diff --git a/crates/engine/src/tests/arrowheads.rs b/crates/engine/src/tests/arrowheads.rs index 2fdbbebca9..6b31fbcfda 100644 --- a/crates/engine/src/tests/arrowheads.rs +++ b/crates/engine/src/tests/arrowheads.rs @@ -283,3 +283,68 @@ fn an_unreadable_arrowheads_file_lists_no_heads_and_says_why() { assert!(bare.execute("stroke.arrowheads.edit", &json!({})).is_err()); assert_eq!(bare.execute("stroke.arrowheads.list", &json!({})).unwrap()["file"], serde_json::Value::Null); } + +#[test] +fn the_heads_listed_after_a_save_are_read_from_the_file_and_editing_keeps_it() { + let dir = folder("saved"); + let file = dir.join(arrowheads::FILE_NAME).to_string_lossy().to_string(); + let (mut s, _) = session_with_line(); + s.attach_arrowheads_file(file.clone()); + assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], true); + // A speck that saving rounds to a point is a head only before the save. + let st = s.doc_mut().unwrap(); + add_symbol(Arc::make_mut(&mut st.doc), "Speck", polygon(&[(0.0, 0.0), (0.3, 0.1), (0.0, 0.2)], filled())); + assert!(heads_of(&s.doc().unwrap().doc).heads.iter().any(|h| h.name == "Speck"), "a head before rounding"); + s.execute("document.save", &json!({"precision": 0})).unwrap(); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + let names: Vec<&str> = list["arrowheads"].as_array().unwrap().iter().filter_map(|h| h["name"].as_str()).collect(); + assert_eq!(names, ["Example Arrowhead"]); + assert_eq!(list["skipped"][0]["name"], "Speck", "{list}"); + // Editing again leaves the file as it is. + let saved = std::fs::read(&file).unwrap(); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], false); + assert_eq!(std::fs::read(&file).unwrap(), saved, "the file is left as it was"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn an_arrowheads_file_that_unpacks_past_the_size_limit_is_not_read() { + let dir = folder("unpacked"); + let file = dir.join(arrowheads::FILE_NAME); + let mut d = arrowheads::starter(); + d.title = "a".repeat(arrowheads::MAX_FILE_BYTES as usize); + let bytes = vectorcraft_format::save_with(&d, &vectorcraft_format::SaveOptions { compress: true, ..Default::default() }).unwrap(); + assert!((bytes.len() as u64) < arrowheads::MAX_FILE_BYTES / 16, "compressed small: {}", bytes.len()); + std::fs::write(&file, &bytes).unwrap(); + let mut s = Session::new(); + s.attach_arrowheads_file(file.to_string_lossy().to_string()); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert!(list["arrowheads"].as_array().unwrap().is_empty(), "{}", list["arrowheads"]); + assert!(list["error"].is_string(), "{}", list["error"]); + // The same head in a file of an ordinary size is read. + std::fs::write(&file, vectorcraft_format::save_file(&arrowheads::starter())).unwrap(); + assert_eq!(s.execute("stroke.arrowheads.reload", &json!({})).unwrap()["count"], 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_symbol_whose_shapes_cannot_be_combined_is_skipped() { + let wedge = || Arc::new(polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled())); + // Too far out for the shape operations to measure: the union would leave it out. + let far = || Arc::new(polygon(&[(0.0, 0.0), (1e200, 0.0), (0.0, 1e200)], filled())); + let mut d = Document::new(100.0, 100.0); + add_symbol(&mut d, "Wedge", (*wedge()).clone()); + add_symbol(&mut d, "Broken", Node::group(NodeId(0), vec![wedge(), far()])); + // The same as clipped art. + let mut mask = polygon(&[(0.0, -10.0), (20.0, -10.0), (20.0, 10.0), (0.0, 10.0)], Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut mask.kind { + *clipping = true; + } + add_symbol(&mut d, "Broken Clip", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), wedge(), far()], clip: true })); + let read = heads_of(&d); + let names: Vec<&str> = read.heads.iter().map(|h| h.name.as_str()).collect(); + assert_eq!(names, ["Wedge"]); + let skipped: Vec<&str> = read.skipped.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(skipped, ["Broken", "Broken Clip"], "{:?}", read.skipped); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index cd64fcbb50..f3efb3784b 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -504,11 +504,23 @@ pub fn load_file(bytes: &[u8]) -> Result { load_file_within(bytes, vectorcraft_doc::node::MAX_READ_NODES, vectorcraft_doc::node::MAX_BUFFERED_VALUES) } +/// [`load_file`] with a compressed file refused when its JSON, or the data after it that the +/// document uses (images, profiles), unpacks to more than `max` bytes (at most [`MAX_JSON`] and +/// [`MAX_BLOBS`]), or, when it lacks the marker after its JSON, when all of it does. +pub fn load_file_capped(bytes: &[u8], max: u64) -> Result { + let file = unpack::Unpacked::within_capped(bytes, max.min(MAX_JSON), max.min(MAX_UNPACKED), max.min(MAX_BLOBS))?; + load_unpacked(file, vectorcraft_doc::node::MAX_READ_NODES, vectorcraft_doc::node::MAX_BUFFERED_VALUES) +} + /// [`load_file`] reading at most `max_nodes` objects and buffering at most `max_values` values /// before objects' types, so a test can exercise the budgets without documents that large /// ([`vectorcraft_doc::node::MAX_READ_NODES`], [`vectorcraft_doc::node::MAX_BUFFERED_VALUES`]). fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result { - let file = unpack::Unpacked::new(bytes)?; + load_unpacked(unpack::Unpacked::new(bytes)?, max_nodes, max_values) +} + +/// The document of `file`, within the budgets of [`load_file_within`]. +fn load_unpacked(file: unpack::Unpacked<'_>, max_nodes: usize, max_values: usize) -> Result { // The JSON reader stops at 128 levels (a document's objects use three each), the paths read // at `MAX_READ_ANCHORS` anchors together, the document at `MAX_READ_NODES` objects, and the // fields read before an object's type at `MAX_BUFFERED_VALUES` values. The preview and the @@ -1186,6 +1198,30 @@ mod tests { assert_eq!(back.node(id).unwrap().path_data(), d.node(id).unwrap().path_data()); } + /// A capped read refuses a compressed file whose images unpack past the cap, before reading + /// them, and reads the same file under a cap it fits. + #[test] + fn a_capped_load_caps_the_data_after_the_json() { + use std::io::Write as _; + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + d.images.insert("k".into(), ImageBlob::new("image/png", vec![7; 1 << 16])); + let img = d.alloc_id(); + let im = ImageObject { key: "k".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + d.insert(Some(l), 0, Node::new(img, NodeKind::Image(Box::new(im)))).unwrap(); + // The file gzipped whole, image and all, so the image unpacks from a few bytes. + let raw = save_with(&d, &SaveOptions::default()).unwrap(); + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best()); + gz.write_all(&raw).unwrap(); + let bytes = gz.finish().unwrap(); + assert!(is_compressed(&bytes) && bytes.len() < 1 << 12, "{}", bytes.len()); + let json = unpack::Unpacked::new(&bytes).unwrap().json().len(); + assert!(json < 1 << 15, "{json}"); + assert!(matches!(load_file_capped(&bytes, (1 << 16) - 1), Err(FormatError::TooBig))); + assert_eq!(load_file_capped(&bytes, 3 << 15).unwrap().doc.images["k"].bytes.len(), 1 << 16); + assert_eq!(load_file(&bytes).unwrap().doc.images["k"].bytes.len(), 1 << 16); + } + /// #3587: objects are read within a budget. A compressed file of many small objects stays /// small on disk while they take far more memory once read, so past the budget it is refused /// with a message naming it, before the rest of the document is read. diff --git a/crates/format/src/unpack.rs b/crates/format/src/unpack.rs index 1573fa0bc7..fed726bfab 100644 --- a/crates/format/src/unpack.rs +++ b/crates/format/src/unpack.rs @@ -28,6 +28,10 @@ pub(crate) type Entry = Option>>; pub(crate) struct Unpacked<'a> { json: Json<'a>, tail: Tail<'a>, + /// Most bytes the entries after the JSON may have together ([`MAX_BLOBS`] unless capped). + max_blobs: u64, + /// Most bytes unpacked to find them in a streamed file ([`MAX_WORK`] unless capped). + max_work: u64, } enum Json<'a> { @@ -68,9 +72,23 @@ impl<'a> Unpacked<'a> { /// a document several times its size, and the cap is what keeps that off the address space /// (#3587). fn within(bytes: &'a [u8], max_json: u64) -> Result { + Self::within_capped(bytes, max_json, MAX_UNPACKED, MAX_BLOBS) + } + + /// [`Self::within`], with a compressed file that lacks the marker after its JSON (unpacked + /// whole) capped at `max_whole` bytes instead of [`MAX_UNPACKED`], the entries after the JSON + /// at `max_blobs` bytes together instead of [`MAX_BLOBS`], and the bytes unpacked to find them + /// at `max_json` and `max_blobs` together instead of [`MAX_WORK`]. + pub(crate) fn within_capped(bytes: &'a [u8], max_json: u64, max_whole: u64, max_blobs: u64) -> Result { + let (json, tail) = Self::open(bytes, max_json, max_whole)?; + Ok(Self { json, tail, max_blobs, max_work: max_json.saturating_add(max_blobs).min(MAX_WORK) }) + } + + /// The JSON of `bytes` and where the data after it is, within `max_json` and `max_whole`. + fn open(bytes: &'a [u8], max_json: u64, max_whole: u64) -> Result<(Json<'a>, Tail<'a>), FormatError> { if !is_compressed(bytes) { let (json, tail) = split_tail(bytes); - return Ok(Self { json: Json::Borrowed(json), tail: Tail::Slice(tail) }); + return Ok((Json::Borrowed(json), Tail::Slice(tail))); } let mut dec = GzDecoder::new(bytes); let mut buf = Vec::new(); @@ -93,7 +111,7 @@ impl<'a> Unpacked<'a> { if buf.len() as u64 > max_json { return Err(FormatError::TooBig); } - return Ok(Self { json: Json::Owned(buf), tail: Tail::None }); + return Ok((Json::Owned(buf), Tail::None)); } // The marker where the writer puts it, right after the JSON's last byte: what is unpacked // so far reaches past it. @@ -104,17 +122,17 @@ impl<'a> Unpacked<'a> { let read = buf.split_off(end + BLOB_MAGIC.len()); buf.truncate(end); let at = (end + BLOB_MAGIC.len()) as u64; - return Ok(Self { json: Json::Owned(buf), tail: Tail::Stream { file: bytes, at, read, rest: Box::new(dec) } }); + return Ok((Json::Owned(buf), Tail::Stream { file: bytes, at, read, rest: Box::new(dec) })); } // Anything else (hand-made or damaged files) is unpacked whole and split as an // uncompressed file is. - dec.take(MAX_UNPACKED + 1 - (buf.len() as u64).min(MAX_UNPACKED)).read_to_end(&mut buf).map_err(damaged)?; - if buf.len() as u64 > MAX_UNPACKED { + dec.take(max_whole + 1 - (buf.len() as u64).min(max_whole)).read_to_end(&mut buf).map_err(damaged)?; + if buf.len() as u64 > max_whole { return Err(FormatError::TooBig); } let (json, tail) = split_tail(&buf); let (json, tail) = (json.len(), buf.len() - tail.len()); - Ok(Self { json: Json::Whole { buf, json }, tail: Tail::Whole(tail) }) + Ok((Json::Whole { buf, json }, Tail::Whole(tail))) } /// The JSON text. @@ -133,13 +151,16 @@ impl<'a> Unpacked<'a> { /// of an uncompressed file larger together than both [`MAX_BLOBS`] and the data, or the /// distinct entries found larger together than the data they are in (overlapping entries /// would copy the same bytes once each, #3574). Entries are read in the order of their - /// offsets, each straight into its own buffer. + /// offsets, each straight into its own buffer. A capped file ([`Self::within_capped`]) takes + /// its own totals in place of [`MAX_BLOBS`] and [`MAX_WORK`]. pub(crate) fn entries(self, wanted: &[[u64; 2]]) -> Result, FormatError> { - self.entries_within(wanted, MAX_WORK) + let work = self.max_work; + self.entries_within(wanted, work) } /// [`Self::entries`], unpacking at most `work` bytes of a streamed file to find them. fn entries_within(self, wanted: &[[u64; 2]], work: u64) -> Result, FormatError> { + let max_blobs = self.max_blobs; let mut out: Vec = vec![None; wanted.len()]; // Data in memory is read through the crate's `Entries`: entries naming the same bytes // share them, and the bytes copied stay within the data. @@ -155,11 +176,11 @@ impl<'a> Unpacked<'a> { }; match self.tail { Tail::Slice(tail) => { - within(wanted, u64::MAX, MAX_BLOBS.max(tail.len() as u64))?; + within(wanted, u64::MAX, max_blobs.max(tail.len() as u64))?; in_memory(tail, &mut out)?; } Tail::Whole(at) => { - within(wanted, MAX_BLOB, MAX_BLOBS)?; + within(wanted, MAX_BLOB, max_blobs)?; let buf = match &self.json { Json::Whole { buf, .. } => buf.as_slice(), _ => &[], @@ -168,7 +189,7 @@ impl<'a> Unpacked<'a> { } Tail::None => in_memory(&[], &mut out)?, Tail::Stream { file, at, read, rest } => { - within(wanted, MAX_BLOB, MAX_BLOBS)?; + within(wanted, MAX_BLOB, max_blobs)?; drop(self.json); // The entries read so far, by `[offset, length]`, and the bytes copied for them. let mut done: HashMap<[u64; 2], Entry> = HashMap::new(); @@ -194,8 +215,8 @@ impl<'a> Unpacked<'a> { done.insert(w, slot.clone()); } // To the end, so the stream's checksum is verified as before. - s.left = MAX_BLOBS; - s.skip(MAX_BLOBS)?; + s.left = max_blobs; + s.skip(max_blobs)?; } } Ok(out) @@ -317,6 +338,13 @@ mod tests { // An offset past the data is still just missing, and the default budget reads all of it. assert_eq!(read(&[[1 << 40, 1]], 1 << 20).unwrap(), vec![None]); assert_eq!(Unpacked::new(&file).unwrap().entries(&[[0, 4096]]).unwrap(), vec![Some(Arc::new(tail.clone()))]); + // A capped file finds its entries within its own caps: a small entry far into the data, + // or entries larger than the cap together, are refused. + let capped = || Unpacked::within_capped(&file, 64, MAX_UNPACKED, 16).unwrap(); + assert_eq!(capped().entries(&[[0, 16]]).unwrap(), vec![Some(Arc::new(tail[..16].to_vec()))]); + assert!(matches!(capped().entries(&[[4000, 1]]), Err(FormatError::TooBig))); + assert!(matches!(capped().entries(&[[0, 17]]), Err(FormatError::TooBig))); + assert_eq!(Unpacked::new(&file).unwrap().entries(&[[4000, 1]]).unwrap(), vec![Some(Arc::new(vec![tail[4000]]))]); } /// #3587: a compressed file's JSON is capped wherever the cap runs, so a small gzip that diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index 7be5b65e7c..894b8488f8 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -367,22 +367,44 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } /// The document an arrowhead preview renders: a `w`×`h` pt line with head `a` at its left -/// (`start`) or right end, its tip on the end point, drawn white (the preview is tinted). -pub(super) fn arrow_doc(a: Option, start: bool, w: f64, h: f64) -> Option { - let mut doc = Document::new(w, h); - let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 1.5); - let st = ap.stroke_mut()?; +/// (`start`) or right end, its tip on the end point, drawn white (the preview is tinted). A custom +/// head is made smaller than the built-in ones when it would not fit (below the smallest head +/// weight, its outline is scaled down), and the line's end moves in by as much as the head reaches +/// past its tip. +pub(super) fn arrow_doc(mut a: Option, start: bool, w: f64, h: f64) -> Option { + const WIDTH: f64 = 1.5; + // The smallest weight `StrokeLayer::arrow_weight` gives a head. + const LEAST: f64 = 0.25; // Heads of weight 2.5 pt: 10 pt long and wide. - st.arrow_scale = (500.0 / 3.0, 500.0 / 3.0); + let mut scale: f64 = 500.0 / 3.0; + let mut inset = 0.0; + if let Some(Arrowhead::Custom(c)) = &a + && let Some(b) = c.path.control_bounds() + { + let (long, half) = (b.x1.max(0.0) - b.x0.min(0.0), b.y0.abs().max(b.y1.abs())); + let fit = |room: f64, units: f64| if units > 1e-9 { room.max(0.0) / units } else { f64::INFINITY }; + let weight = fit(w - 6.0, long).min(fit(h / 2.0 - 1.0, half)).min(2.5); + inset = -b.x0.min(0.0) * weight; + if weight < LEAST { + let path = c.path.transformed(vectorcraft_geom::Affine::scale(weight / LEAST)); + a = Some(Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new(c.name.clone(), path)))); + } + scale = scale.min(weight.max(LEAST) / WIDTH * 100.0); + } + let mut doc = Document::new(w, h); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::WHITE), WIDTH); + let st = ap.stroke_mut()?; + st.arrow_scale = (scale, scale); st.arrow_align = ArrowAlign::Tip; if start { st.start_arrow = a } else { st.end_arrow = a } + let (x0, x1) = if start { (3.0 + inset, w - 3.0) } else { (3.0, w - 3.0 - inset) }; let mut bp = vectorcraft_geom::BezPath::new(); - bp.move_to((3.0, h / 2.0)); - bp.line_to((w - 3.0, h / 2.0)); + bp.move_to((x0, h / 2.0)); + bp.line_to((x1, h / 2.0)); let (id, l) = (doc.alloc_id(), doc.layers[0].id); doc.insert(Some(l), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?; Some(doc) @@ -419,7 +441,10 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option<&Arrowhead>, custom: &[Arro } else if rr.hovered() { ui.painter().rect_filled(row, 0.0, t.hover); } - paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong); + // Only the rows in view render their previews. + if ui.is_rect_visible(row) { + paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong); + } ui.painter().text( row.left_center() + vec2(66.0, 0.0), egui::Align2::LEFT_CENTER, diff --git a/crates/ui-egui/src/panels/tests_strokedepth.rs b/crates/ui-egui/src/panels/tests_strokedepth.rs index c085ccf385..1d80cc1c66 100644 --- a/crates/ui-egui/src/panels/tests_strokedepth.rs +++ b/crates/ui-egui/src/panels/tests_strokedepth.rs @@ -111,6 +111,43 @@ fn every_arrowhead_preview_draws_its_head() { assert!(ink(Some(custom), true) > plain.1 + 20); } +#[test] +fn a_long_custom_arrowhead_preview_fits_in_its_box() { + use vectorcraft_doc::Arrowhead; + // 40 units long and 4 wide, reaching 4 units past its tip. + let pts = [(-4.0, -2.0), (36.0, -2.0), (36.0, 2.0), (-4.0, 2.0)].map(|(x, y)| vectorcraft_geom::Point::new(x, y)); + let bar = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)); + // 120 units across: smaller than the smallest head weight can draw it. + let pts = [(0.0, -60.0), (4.0, -60.0), (4.0, 60.0), (0.0, 60.0)].map(|(x, y)| vectorcraft_geom::Point::new(x, y)); + let wall = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)); + let heads = [bar, wall].map(|p| Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new("Bar".into(), p)))); + for (head, start) in heads.iter().flat_map(|h| [(h, false), (h, true)]) { + let d = stroke::arrow_doc(Some(head.clone()), start, 56.0, 22.0).unwrap(); + // Rendered well beyond the 56 × 22 pt box, at 1 pixel a point: all the ink is inside it. + let img = vectorcraft_render::Renderer::new().render_region(&d, vectorcraft_geom::Rect::new(-100.0, -50.0, 156.0, 72.0), 1.0, false); + let ink: Vec<(u32, u32)> = (0..256).flat_map(|x| (0..122).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] > 64).collect(); + assert!(ink.len() > 40, "start {start}: the line and head draw"); + let outside: Vec<&(u32, u32)> = ink.iter().filter(|&&(x, y)| !(100..156).contains(&x) || !(50..72).contains(&y)).collect(); + assert!(outside.is_empty(), "start {start}: {} pixels outside the box, e.g. {:?}", outside.len(), outside.first()); + } +} + +#[test] +fn the_arrowhead_menu_renders_only_the_previews_in_view() { + let ctx = egui::Context::default(); + let mut app = app_with_line(); + let t = frame_events(&ctx, &mut app, vec![], stroke::show); + let label = text_rect(&t, "Arrowheads:"); + let sp = ctx.global_style().spacing.item_spacing.x; + click(&ctx, &mut app, egui::pos2(label.right() + 2.0 * sp + 64.0 + 32.0, label.center().y), stroke::show); + frame_events(&ctx, &mut app, vec![], stroke::show); + frame_events(&ctx, &mut app, vec![], stroke::show); + let previews = ctx.tex_manager().read().allocated().filter(|(_, m)| m.name.contains(":arrow:") && m.name.ends_with(":false")).count(); + // The button's and the rows' in the 360 pt list, not one for each of the menu's heads. + let rows = vectorcraft_doc::Arrowhead::ALL.len() + 1; + assert!(previews > 5 && previews < rows / 2 + 5, "{previews} previews for {rows} rows"); +} + #[test] fn arrowhead_dropdown_offers_the_new_heads() { let ctx = egui::Context::default(); diff --git a/docs/mcp.md b/docs/mcp.md index aa6f7bcd69..a306e8061e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3960,17 +3960,18 @@ right, the symbol's registration point is its tip, and the art's height is drawn head weights). The visible fills and the outlined visible strokes of its paths, compound paths and groups, and its gradient meshes, make the head, with geometry effects applied and clip groups clipped, and the line stops halfway along its length. A head is one solid shape in the stroke's paint: art at 0 % opacity is left out, and other opacities, blend modes and opacity -masks do not change the shape. The file is read when the app starts and again each time VectorCraft saves it; -`stroke.arrowheads.reload` reads it again. A file larger than 64 MB is not read, at most 256 symbols are read, and a -symbol that paints nothing, reaches more than 64 head weights from its tip, has more than 65,536 anchors in its art or -in the shapes it paints, or has more than 4,096 anchors once united is listed in `skipped`. `stroke.arrowheads.list` returns each head's `name`, `reach` and `halfWidth`, the `file` +masks do not change the shape. The file is read when the app starts and again from disk each time VectorCraft saves it; +`stroke.arrowheads.reload` reads it again. A file larger than 64 MB, or whose document or images unpack to more than 64 MB, is +not read, at most 256 symbols are read, and a symbol that paints nothing, reaches more than 64 head weights from its +tip, has more than 65,536 anchors in its art or in the shapes it paints, has shapes that cannot be combined into one, +or has more than 4,096 anchors once united is listed in `skipped`. `stroke.arrowheads.list` returns each head's `name`, `reach` and `halfWidth`, the `file` path, the `skipped` symbols and any read `error`. `stroke.set {endArrow: {"custom": name}}` puts a custom head on a stroke. The stroke keeps a copy of the head's art, so the document draws it, saves it and exports it to SVG and PDF the same way without the arrowheads file. `document.inspect` reports such a head as `{"Custom": {name, path}}`, which `stroke.set` takes back unchanged. It refuses a `path` that fills no area (non-zero rule; parts thinner than a millionth of a head unit count as none) or -has more than 4,096 anchors or a point more than 64 head units from the tip. The help page +has more than 4,096 anchors, more than 4,096 subpaths or a point more than 64 head units from the tip. The help page [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) describes defining custom arrowheads in a separate arrowheads file. From 61298535550e526b820b56bee43446f416deab4c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:07:12 -0800 Subject: [PATCH 06/34] Effects: a Transform effect transforms the raster effects listed before it Effects apply in the order they are listed, so a Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect paints art that the Transform effect then transforms. VectorCraft drew such an effect at its own distances around the transformed art: a square scaled to 200% kept a 10 pt shadow offset. Now the effect follows the Transform effects after it, with Scale Strokes & Effects on or off: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. Effects listed after the Transform effect keep their values, and Transform Objects off changes nothing. - raster_effects reads each raster effect through the Transform effects after it, so the canvas, export and the redraw region use the followed values. - Where the geometry effects are applied and removed (Expand Appearance, baking for export, evaluated groups), the raster effects that remain carry the same transformation, hidden ones included. - A Transform effect that makes copies or moves at random leaves the raster effects before it as they are. Rasterize and SVG filters keep their options. Docs: the Transform effect's params text, docs/target-app-parity.md. --- crates/effects/src/bake.rs | 13 +- crates/effects/src/catalog.rs | 2 +- crates/effects/src/group.rs | 5 +- crates/effects/src/raster.rs | 94 +++++++++++- crates/effects/src/reshape.rs | 2 +- crates/effects/src/tests.rs | 1 + crates/effects/src/tests/rasterfollow.rs | 173 +++++++++++++++++++++++ crates/render/src/fx/tests.rs | 30 ++++ docs/target-app-parity.md | 1 + 9 files changed, 307 insertions(+), 14 deletions(-) create mode 100644 crates/effects/src/tests/rasterfollow.rs diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 795fc69bdf..24f3d68af6 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -29,7 +29,7 @@ use crate::group::{has_own_paint, paints}; use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; use crate::{ GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, - has_item_geometry, has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape, + has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape, }; fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { @@ -41,8 +41,8 @@ fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { fn clear_item_effects(item: &mut AppearanceItem) { match item { - AppearanceItem::Fill(f) => f.effects.retain(|e| !is_geometry(&e.id)), - AppearanceItem::Stroke(s) => s.effects.retain(|e| !is_geometry(&e.id)), + AppearanceItem::Fill(f) => f.effects = crate::raster::without_geometry(&f.effects), + AppearanceItem::Stroke(s) => s.effects = crate::raster::without_geometry(&s.effects), } } @@ -337,7 +337,7 @@ fn moved_type_group(n: &Node) -> Option { g.knockout = Knockout::Off; g.knockout_shape = n.knockout_shape; g.extras_mut().mask = n.extras().mask.clone(); - g.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + g.appearance.effects = crate::raster::without_geometry(&n.appearance.effects); Some(g) } @@ -397,7 +397,8 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { return None; } reshape_leaves(&mut m, &fx, &n.appearance.effects, Some(d)); - m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id)); + m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); + m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); m.appearance.items.clear(); m.appearance.contents_index = None; Some(m) @@ -439,7 +440,7 @@ fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> { if carry.moves(&m.appearance) { carry.apply(&mut m.appearance); } - m.appearance.effects.retain(|e| !is_geometry(&e.id)); + m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); Some((g, rule, ctx, m)) } diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 866d9b32f3..56d9cf601e 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -265,7 +265,7 @@ pub fn effect_catalog() -> Vec { "distort.transform", "Transform…", DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), ), g( diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 57f2fc8613..015490a74d 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -265,7 +265,7 @@ fn reshaped(src: &Node, n: &Node, doc: Option<&Document>) -> Node { fn without_geometry_effects(n: &mut Node) { if n.appearance.effects.iter().any(|e| crate::is_geometry(&e.id)) { let mut ap = (*n.appearance).clone(); - ap.effects.retain(|e| !crate::is_geometry(&e.id)); + ap.effects = crate::raster::without_geometry(&ap.effects); n.appearance = ap.into(); } } @@ -794,7 +794,8 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { carry.apply(&mut m.appearance); } } - m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id)); + m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); + m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); if paint { let shapes = member_shapes(doc, &m); let bounds = m.geometric_bounds(); diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index cfa51ca2b0..d79aac09cc 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -1,11 +1,13 @@ //! Raster (painted) effects: descriptions for the renderer. -use serde_json::Value; +use std::borrow::Cow; + +use serde_json::{Map, Value, json}; use vectorcraft_doc::color::{BlendMode, Color}; use vectorcraft_doc::rastersettings::MAX_ADD_AROUND; use vectorcraft_doc::{Effect, RasterColorModel, RasterEffectsSettings}; -use vectorcraft_geom::{Point, Rect}; +use vectorcraft_geom::{Affine, Point, Rect}; use crate::merged_params; use crate::pixel::{self, PixelFx, PixelSpace}; @@ -384,9 +386,10 @@ fn opacity(p: &Value) -> f32 { (num(p, "opacity", 75.0) / 100.0).clamp(0.0, 1.0) as f32 } -/// The visible raster effects of `effects`, in stack order. +/// The visible raster effects of `effects`, in stack order, each following the Transform effects +/// listed after it ([`followed`]). pub fn raster_effects(effects: &[Effect]) -> Vec { - effects + followed(effects) .iter() .filter(|e| e.visible) .filter_map(|e| { @@ -426,6 +429,89 @@ pub fn raster_effects(effects: &[Effect]) -> Vec { .collect() } +/// `effects` with each raster effect following the visible Transform effects listed after it +/// ([`follow_transform`]): effects apply in the order they are listed, so a Transform effect +/// transforms what the raster effects before it paint, whatever its Scale Strokes & Effects says. +/// A hidden raster effect follows too, so it draws the same when it is shown after the Transform +/// effect has been expanded. A raster effect that a Transform effect making copies or moving at +/// random follows stays as it is, as does one after the last Transform effect. Borrowed when +/// nothing changes. +pub(crate) fn followed(effects: &[Effect]) -> Cow<'_, [Effect]> { + if !effects.iter().any(|e| e.visible && e.id == TRANSFORM) { + return Cow::Borrowed(effects); + } + let mut out: Option> = None; + // The linear part of the Transform effects after the effect at hand, the last one outermost; + // `None` once one of them makes copies or moves at random. + let mut after = Some(Affine::IDENTITY); + for (i, e) in effects.iter().enumerate().rev() { + if e.id == TRANSFORM { + if !e.visible { + continue; + } + let p = merged_params(&e.id, &e.params); + // With Transform Objects off the art stays where it is. + if flag(&p, "objects", true) { + let (map, copies) = crate::distort::transform_map(Rect::ZERO, &p, 0); + after = after.filter(|_| copies == 0 && !flag(&p, "random", false)).map(|a| a * linear(map)); + } + continue; + } + let Some(a) = after.filter(|a| *a != Affine::IDENTITY && follows(&e.id)) else { continue }; + let list = out.get_or_insert_with(|| effects.to_vec()); + if let Some(e) = list.get_mut(i) { + follow_transform(e, a); + } + } + out.map_or(Cow::Borrowed(effects), Cow::Owned) +} + +/// `effects` without their geometry effects, each raster effect following the Transform effects +/// it loses ([`followed`]): what an object keeps once its geometry effects are applied to its art. +pub(crate) fn without_geometry(effects: &[Effect]) -> Vec { + followed(effects).iter().filter(|e| !crate::is_geometry(&e.id)).cloned().collect() +} + +/// The Transform effect's id. +const TRANSFORM: &str = "distort.transform"; + +/// Does raster effect `id` follow the Transform effects listed after it ([`followed`])? An SVG +/// filter does not, and neither does Rasterize, whose image keeps its own pixel grid and room +/// around the art. +fn follows(id: &str) -> bool { + crate::is_raster(id) && id != crate::SVG_FILTER && id != RASTERIZE +} + +/// `a` without its translation. +fn linear(a: Affine) -> Affine { + let [a0, a1, a2, a3, _, _] = a.as_coeffs(); + Affine::new([a0, a1, a2, a3, 0.0, 0.0]) +} + +/// Make raster effect `e` follow `a`, the linear part of a Transform effect listed after it, as +/// that effect transforms what `e` paints: its distances and raster sizes scale by `a`'s mean +/// scale ([`crate::scale_effect`]), and a Drop Shadow's offset turns, reflects and scales with +/// `a`. Effects that [`follows`] leaves out stay as they are. +fn follow_transform(e: &mut Effect, a: Affine) { + if !follows(&e.id) { + return; + } + let offset = (e.id == "stylize.dropShadow").then(|| { + let p = merged_params(&e.id, &e.params); + a * Point::new(num(&p, "x", 7.0), num(&p, "y", 7.0)) + }); + if let Some(k) = vectorcraft_doc::Scaling::factor(a) { + crate::scale_effect(e, k); + } + if let Some(o) = offset.filter(|o| o.x.is_finite() && o.y.is_finite()) { + if !e.params.is_object() { + e.params = Value::Object(Map::new()); + } + e.params["x"] = json!(o.x); + e.params["y"] = json!(o.y); + } +} + /// How far the visible raster effects of `effects` paint beyond `bounds`, the (effected) /// geometry's, in document units. Geometry effects are accounted for by evaluating them. pub fn outset(effects: &[Effect], bounds: Rect) -> f64 { diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index c629cc10ca..36e34a90b4 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -594,7 +594,7 @@ fn as_art_opts( out.knockout = vectorcraft_doc::Knockout::Off; out.knockout_shape = n.knockout_shape; out.extras_mut().mask = n.extras().mask.clone(); - out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + out.appearance.effects = crate::raster::without_geometry(&n.appearance.effects); Some(out) } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 532251bd73..b1616c676d 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes}; use super::*; +mod rasterfollow; mod scale; mod transformfx; diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs new file mode 100644 index 0000000000..8212c11487 --- /dev/null +++ b/crates/effects/src/tests/rasterfollow.rs @@ -0,0 +1,173 @@ +//! Raster effects listed before a Transform effect follow it: the Transform effect transforms +//! what they paint. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId}; +use vectorcraft_geom::{Rect, shapes}; + +use crate::{RasterFx, bake_document, expand_outlined, raster_effects}; + +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +fn shadow(x: f64, y: f64, blur: f64) -> Effect { + fx("stylize.dropShadow", json!({"x": x, "y": y, "blur": blur})) +} + +fn transform(params: Value) -> Effect { + fx("distort.transform", params) +} + +/// The offset and blur of the first Drop Shadow `effects` draw. +fn drawn_shadow(effects: &[Effect]) -> (f64, f64, f64) { + match raster_effects(effects).first() { + Some(RasterFx::DropShadow { dx, dy, blur, .. }) => (*dx, *dy, *blur), + other => panic!("expected a drop shadow, got {other:?}"), + } +} + +fn close(a: (f64, f64, f64), b: (f64, f64, f64)) -> bool { + (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9 && (a.2 - b.2).abs() < 1e-9 +} + +/// A 50 pt red square with `effects`. +fn square(id: u64, effects: Vec) -> Node { + let mut n = Node::path( + NodeId(id), + shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + n.appearance.effects = effects; + n +} + +#[test] +fn a_transform_after_a_drop_shadow_scales_its_offset_and_blur() { + for strokes in [false, true] { + let effects = [shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200, "strokes": strokes}))]; + assert!(close(drawn_shadow(&effects), (20.0, 20.0, 8.0)), "Scale Strokes & Effects {strokes}: {:?}", drawn_shadow(&effects)); + } +} + +#[test] +fn a_drop_shadow_after_a_transform_keeps_its_offset() { + let effects = [transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 10.0, 4.0)]; + assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{:?}", drawn_shadow(&effects)); +} + +#[test] +fn the_offset_follows_an_uneven_scale_a_rotation_and_a_reflection() { + let uneven = [shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 100}))]; + assert!(close(drawn_shadow(&uneven), (20.0, 10.0, 4.0 * 2f64.sqrt())), "{:?}", drawn_shadow(&uneven)); + // A turn of 90° counterclockwise on screen takes an offset to the right to one upward. + let turned = [shadow(10.0, 0.0, 4.0), transform(json!({"rotate": 90}))]; + let (dx, dy, blur) = drawn_shadow(&turned); + assert!(dx.abs() < 1e-9 && (dy + 10.0).abs() < 1e-9 && (blur - 4.0).abs() < 1e-9, "{dx} {dy} {blur}"); + let reflected = [shadow(10.0, 0.0, 4.0), transform(json!({"reflectX": true}))]; + assert!(close(drawn_shadow(&reflected), (-10.0, 0.0, 4.0)), "{:?}", drawn_shadow(&reflected)); +} + +#[test] +fn transforms_after_the_shadow_apply_in_order() { + let effects = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), transform(json!({"rotate": 90}))]; + let (dx, dy, _) = drawn_shadow(&effects); + assert!(dx.abs() < 1e-9 && (dy + 20.0).abs() < 1e-9, "{dx} {dy}"); + // Only the Transform after a shadow moves it. + let between = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), shadow(0.0, 10.0, 0.0)]; + let drawn = raster_effects(&between); + let offsets: Vec<(f64, f64)> = drawn + .iter() + .filter_map(|r| match r { + RasterFx::DropShadow { dx, dy, .. } => Some((*dx, *dy)), + _ => None, + }) + .collect(); + assert_eq!(offsets, vec![(20.0, 0.0), (0.0, 10.0)]); +} + +#[test] +fn transforms_that_leave_the_art_or_copy_it_leave_the_shadow() { + for params in [ + json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}), + json!({"scaleH": 200, "scaleV": 200, "copies": 1}), + json!({"scaleH": 200, "scaleV": 200, "random": true}), + ] { + let effects = [shadow(10.0, 10.0, 4.0), transform(params.clone())]; + assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{params}: {:?}", drawn_shadow(&effects)); + } + let mut hidden = transform(json!({"scaleH": 200, "scaleV": 200})); + hidden.visible = false; + let effects = [shadow(10.0, 10.0, 4.0), hidden]; + assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{:?}", drawn_shadow(&effects)); +} + +/// A hidden shadow kept when the Transform effect is applied (Expand Appearance) draws as it would +/// have before, once it is shown. +#[test] +fn a_hidden_shadow_follows_the_transform_it_loses() { + let mut hidden = shadow(10.0, 10.0, 4.0); + hidden.visible = false; + let kept = crate::raster::without_geometry(&[hidden, transform(json!({"scaleH": 200, "scaleV": 200}))]); + assert_eq!(kept.len(), 1); + let mut shown = kept[0].clone(); + shown.visible = true; + assert!(close(drawn_shadow(&[shown]), (20.0, 20.0, 8.0))); +} + +/// Rasterize keeps its room around the art: its image keeps its own pixel grid. +#[test] +fn rasterize_before_a_transform_keeps_its_options() { + let rasterize = fx(crate::RASTERIZE, json!({"addAround": 6})); + let effects = [rasterize.clone(), transform(json!({"scaleH": 200, "scaleV": 200}))]; + assert_eq!(crate::raster::followed(&effects)[0], rasterize); + assert_eq!(crate::raster::without_geometry(&effects), vec![rasterize]); +} + +#[test] +fn glows_blurs_and_feathers_scale_with_the_transform_after_them() { + let effects = [ + fx("stylize.outerGlow", json!({"blur": 5})), + fx("stylize.innerGlow", json!({"blur": 5})), + fx("stylize.feather", json!({"radius": 5})), + fx("blur.gaussian", json!({"radius": 5})), + transform(json!({"scaleH": 50, "scaleV": 50})), + ]; + let sizes: Vec = raster_effects(&effects) + .iter() + .map(|r| match r { + RasterFx::OuterGlow { blur, .. } | RasterFx::InnerGlow { blur, .. } => *blur, + RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius, .. } => *radius, + other => panic!("unexpected {other:?}"), + }) + .collect(); + assert_eq!(sizes, vec![2.5; 4]); + // How far the shadow paints past the object follows too. + let shadowed = [shadow(10.0, 10.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; + assert_eq!(crate::outset(&shadowed, Rect::new(0.0, 0.0, 100.0, 100.0)), 20.0); +} + +#[test] +fn expanded_and_exported_objects_keep_the_shadow_the_canvas_draws() { + let effects = vec![shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; + // A path drawn through its art (Scale Strokes & Effects on), expanded. + let mut with_options = effects.clone(); + with_options[1] = transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})); + let n = square(7, with_options); + let expanded = expand_outlined(None, &n, None).unwrap(); + assert!(close(drawn_shadow(&expanded.appearance.effects), (20.0, 20.0, 8.0)), "{:?}", expanded.appearance.effects); + // A path and a group, baked for export. + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, square(8, effects.clone())).unwrap(); + let mut g = Node::group(NodeId(9), vec![std::sync::Arc::new(square(10, vec![]))]); + g.appearance.effects = effects.clone(); + d.insert(Some(layer), 1, g).unwrap(); + let baked = bake_document(&d).unwrap(); + for id in [8, 9] { + let b = baked.node(NodeId(id)).unwrap(); + assert!(!b.appearance.effects.iter().any(|e| e.id == "distort.transform"), "{id}: {:?}", b.appearance.effects); + assert!(close(drawn_shadow(&b.appearance.effects), (20.0, 20.0, 8.0)), "{id}: {:?}", b.appearance.effects); + } +} diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index c03a6cc0f3..96bddc17f1 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -307,6 +307,36 @@ fn drop_shadow_darkens_outside_the_shape() { assert_eq!(lum(img.pixel(15, 15)), WHITE); } +/// A Transform effect listed after a Drop Shadow transforms the shadow with the art: halving a +/// 40 pt square about its center halves the shadow's 10 pt offset, with Scale Strokes & Effects +/// off or on, and for a group as for a path. +#[test] +fn a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art() { + let r = Rect::new(20.0, 20.0, 60.0, 60.0); + let shadow = ("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal"})); + for strokes in [false, true] { + let transform = ("distort.transform", json!({"scaleH": 50, "scaleV": 50, "strokes": strokes})); + let path = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![shadow.clone(), transform.clone()]); + let mut group = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![]); + let l = group.layers[0].id; + let square = group.layers[0].children().and_then(|c| c.first()).cloned().unwrap(); + group.remove(square.id).unwrap(); + let mut g = Node::group(group.alloc_id(), vec![square]); + g.appearance.effects = [shadow.clone(), transform].into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect(); + group.insert(Some(l), 0, g).unwrap(); + for (what, d) in [("path", path), ("group", group)] { + let img = render(&d); + // The square now covers 30..50 and its shadow 35..55. + assert_eq!(img.pixel(40, 40), [255, 0, 0, 255], "{what}, Scale Strokes & Effects {strokes}"); + assert!(lum(img.pixel(53, 37)) < 100, "{what}, {strokes}: the shadow reaches up to 35: {:?}", img.pixel(53, 37)); + assert_eq!(lum(img.pixel(57, 57)), WHITE, "{what}, {strokes}: the shadow ends at 55"); + // The area redrawn for it takes in the 5 pt shadow around the 30..50 square. + let b = super::cull_bounds(&d.symbol_boxes(), &d.layers[0].children().unwrap()[0]).unwrap(); + assert!((b.x0 - 25.0).abs() < 1e-9 && (b.x1 - 55.0).abs() < 1e-9, "{what}, {strokes}: {b:?}"); + } + } +} + /// A red square's drop shadow at Darkness `darkness` (%), opaque and sharp, 10 pt down and /// right; `shade` "color" or "darkness". fn shaded_doc(mode: vectorcraft_doc::ColorMode, fill: Color, shade: &str, darkness: f64) -> Document { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 351ade4468..b20aadde45 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -589,6 +589,7 @@ since 2026-10-01. - **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. +- **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)), so a Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect is transformed with the art, with Scale Strokes & Effects on or off: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. Before, it was drawn at its own distances around the transformed art. The canvas, export and Expand Appearance agree (effects `tests::rasterfollow`, render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are, and a Rasterize effect before a Transform effect keeps its pixel grid and room around the art. Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. From 88841aa80357fcac6b932dec3c9c873256e602f5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:16:43 -0800 Subject: [PATCH 07/34] Window menu: a panel's entry shows the panel Tab hid instead of closing it The Window menu checked a panel whenever it was in front in the layout, even while Tab, Shift+Tab or Presentation Mode hid every panel, so choosing it, or pressing its function key, closed the panel it was meant to show. - A new panel_docking::visible adds the dock and Presentation Mode to showing. The Window menu's check marks and the toggle use it, and window.panel puts a flyout or floating panel away only while it can be seen. With the dock hidden, the dock shows again with the panel in its earlier place; the toolbar and the Control bar stay as they are. - In Presentation Mode window.panel fails with "panels can't be shown in Presentation Mode" and changes nothing. - reveal_panel, the Gradient and Magic Wand tool double-click, the Character field shortcuts and the Corner and Shared Edge Merge tool items show a panel the dock hid instead of reporting it open or closing a floating Anchor Inspector. Docs: window.panel params text, docs/mcp.md, docs/target-app-parity.md. --- crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/addons/pathtools/corner.rs | 2 +- .../src/addons/pathtools/shared_edge.rs | 2 +- crates/ui-egui/src/addons/pathtools/tests.rs | 18 ++ crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/menus/state.rs | 2 +- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/panel_docking.rs | 35 +++- crates/ui-egui/src/panel_docking/tests.rs | 160 ++++++++++++++++++ crates/ui-egui/src/panels/character.rs | 4 +- crates/ui-egui/src/toolbar.rs | 3 +- crates/ui-egui/src/toolbar/tests.rs | 9 + docs/mcp.md | 5 + docs/target-app-parity.md | 1 + 35 files changed, 250 insertions(+), 17 deletions(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index b07964b225..cc18980e4d 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -594,7 +594,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.panel", "Show Panel", "", - "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that shows, and shows it again where it was", + "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that can be seen, and shows it again where it was; with the dock hidden by Tab or Shift+Tab, the dock shows again with the panel in front, leaving the toolbar and Control bar as they are; fails and changes nothing in Presentation Mode, which hides every panel", ), ( "window.panel.layout", diff --git a/crates/ui-egui/src/addons/pathtools/corner.rs b/crates/ui-egui/src/addons/pathtools/corner.rs index 36ee1bd1c9..ace9be954c 100644 --- a/crates/ui-egui/src/addons/pathtools/corner.rs +++ b/crates/ui-egui/src/addons/pathtools/corner.rs @@ -20,7 +20,7 @@ const FIELD_W: f32 = 96.0; /// Object › Plug-ins › Path Precision Tools › Corner Tool. pub fn open(app: &mut VectorcraftApp) -> Result { app.select_tool(ID); - app.run("window.panel", json!({ "panel": super::ID })) + crate::panel_docking::reveal_panel(app, super::ID) } /// How many corners Apply would edit: the selected paths' direct-selected ones, or all of a path diff --git a/crates/ui-egui/src/addons/pathtools/shared_edge.rs b/crates/ui-egui/src/addons/pathtools/shared_edge.rs index 2e1365ffa0..d682aad776 100644 --- a/crates/ui-egui/src/addons/pathtools/shared_edge.rs +++ b/crates/ui-egui/src/addons/pathtools/shared_edge.rs @@ -18,7 +18,7 @@ const LABEL_W: f32 = 80.0; /// Object › Plug-ins › Path Precision Tools › Shared Edge Merge Tool. pub fn open(app: &mut VectorcraftApp) -> Result { app.select_tool(ID); - app.run("window.panel", json!({ "panel": super::ID })) + crate::panel_docking::reveal_panel(app, super::ID) } pub(super) fn section(app: &mut VectorcraftApp, ui: &mut Ui) { diff --git a/crates/ui-egui/src/addons/pathtools/tests.rs b/crates/ui-egui/src/addons/pathtools/tests.rs index d477fa0363..9ecf12062d 100644 --- a/crates/ui-egui/src/addons/pathtools/tests.rs +++ b/crates/ui-egui/src/addons/pathtools/tests.rs @@ -142,6 +142,24 @@ fn the_corner_tool_item_selects_the_tool_and_opens_its_panel_section() { assert!(!vectorcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).any(|t| t.id == "cornerTool" || t.id == "slideAnchor")); } +/// The Corner Tool and Shared Edge Merge Tool items leave a floating Anchor Inspector showing +/// instead of closing it, and in Presentation Mode they choose the tool without failing. +#[test] +fn the_tool_items_keep_a_floating_anchor_inspector_showing() { + let mut app = app(); + app.run("window.panel.float", json!({ "panel": ID })).unwrap(); + assert!(crate::panel_docking::visible(&app.ui, ID)); + for command in [super::corner::COMMAND, super::shared_edge::COMMAND] { + app.run(command, json!({})).unwrap(); + assert!(crate::panel_docking::visible(&app.ui, ID), "{command} leaves the panel showing"); + } + app.ui.screen_mode = 3; + app.run(super::corner::COMMAND, json!({})).unwrap(); + assert_eq!(app.session.tool_id(), "cornerTool"); + app.ui.screen_mode = 0; + assert!(crate::panel_docking::visible(&app.ui, ID), "the panel shows when Presentation Mode ends"); +} + #[test] fn the_shared_edge_item_selects_the_tool_and_opens_its_panel_section() { let mut app = app(); diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cd6dfad587..1ab64a27ba 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6046,6 +6046,7 @@ digits Malayalam Malaiàlam @msg unknown option `{path}.{k}` opció desconeguda `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) sobreimpressions desconegudes `{o}` (preserve, simulate o discard) @msg unknown panel `{raw}` panell desconegut `{raw}` +@msg panels can't be shown in Presentation Mode els panells no es poden mostrar en el mode de presentació @msg unknown parameter `{key}`; did you mean `{near}`? paràmetre desconegut `{key}`; volies dir `{near}`? @msg unknown parameter `{key}`; the command takes no parameters paràmetre desconegut `{key}`; l’ordre no admet cap paràmetre @msg unknown parameter `{key}`; the command takes: {keys} paràmetre desconegut `{key}`; l’ordre admet: {keys} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 00de3e7057..84dcd7f933 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5089,6 +5089,7 @@ imageTrace Outline Obrys @msg unknown option `{path}.{k}` neznámá možnost `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) neznámé přetisky `{o}` (preserve, simulate nebo discard) @msg unknown panel `{raw}` neznámý panel `{raw}` +@msg panels can't be shown in Presentation Mode v režimu prezentace nelze panely zobrazit @msg unknown preference `{key}` neznámá předvolba `{key}` @msg Preferences imported. Předvolby byly importovány. @msg not a preferences file: {e} nejde o soubor předvoleb: {e} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0de0dce0b9..9748e9d695 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4706,6 +4706,7 @@ theme Appearance Erscheinungsbild @msg unknown option `{path}.{k}` unbekannte Option `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) unbekannter Wert für overprints `{o}` (preserve, simulate oder discard) @msg unknown panel `{raw}` unbekanntes Bedienfeld `{raw}` +@msg panels can't be shown in Presentation Mode im Präsentationsmodus können keine Bedienfelder angezeigt werden @msg unknown preference `{key}` unbekannte Voreinstellung `{key}` @msg Preferences imported. Voreinstellungen importiert. @msg not a preferences file: {e} keine Voreinstellungsdatei: {e} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a75edff7a8..a30dce988f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4730,6 +4730,7 @@ digits Malayalam Μαλαγιάλαμ @msg unknown option `{path}.{k}` άγνωστη επιλογή `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) άγνωστη επιλογή υπερεκτύπωσης `{o}` (preserve, simulate ή discard) @msg unknown panel `{raw}` άγνωστος πίνακας `{raw}` +@msg panels can't be shown in Presentation Mode τα πλαίσια δεν μπορούν να εμφανιστούν στη λειτουργία παρουσίασης @msg unknown preference `{key}` άγνωστη προτίμηση `{key}` @msg Preferences imported. Οι προτιμήσεις εισήχθησαν. @msg not a preferences file: {e} δεν είναι αρχείο προτιμήσεων: {e} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6d548f6743..283bd1b2a6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4781,6 +4781,7 @@ axis Both Ambos @msg unknown option `{path}.{k}` opción desconocida `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) sobreimpresiones desconocidas `{o}` (preserve, simulate o discard) @msg unknown panel `{raw}` panel desconocido `{raw}` +@msg panels can't be shown in Presentation Mode los paneles no se pueden mostrar en el modo de presentación @msg unknown preference `{key}` preferencia desconocida `{key}` @msg Preferences imported. Preferencias importadas. @msg not a preferences file: {e} no es un archivo de preferencias: {e} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7ae54d2bef..d33eaa6432 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4991,6 +4991,7 @@ imageTrace Outline Ääriviiva @msg unknown option `{path}.{k}` tuntematon asetus `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) tuntematon päällepainatusten käsittely `{o}` (preserve, simulate tai discard) @msg unknown panel `{raw}` tuntematon paneeli `{raw}` +@msg panels can't be shown in Presentation Mode paneeleja ei voi näyttää esitystilassa @msg unknown preference `{key}` tuntematon asetus `{key}` @msg Preferences imported. Asetukset tuotiin. @msg not a preferences file: {e} ei ole asetustiedosto: {e} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 35c649f895..df839a9d93 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4770,6 +4770,7 @@ axis Both Les deux @msg unknown option `{path}.{k}` option inconnue `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) surimpressions inconnues `{o}` (preserve, simulate ou discard) @msg unknown panel `{raw}` panneau inconnu `{raw}` +@msg panels can't be shown in Presentation Mode les panneaux ne peuvent pas être affichés en mode Présentation @msg unknown preference `{key}` préférence inconnue `{key}` @msg Preferences imported. Préférences importées. @msg not a preferences file: {e} ce n’est pas un fichier de préférences : {e} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 335b0c99f0..392722c6d3 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8020,6 +8020,7 @@ antiAlias Sharp शार्प @msg unknown overprints `{v}` (preserve, simulate or discard) अज्ञात ओवरप्रिंट `{v}` (प्ररक्षित, अनुकरण या त्यागपत्र) @msg unknown panel अज्ञात पैनल @msg unknown panel `{raw}` अज्ञात पैनल `{raw}` +@msg panels can't be shown in Presentation Mode प्रस्तुति मोड में पैनल्स नहीं दिखाए जा सकते @msg unknown parameter `{key}`; did you mean `{near}`? अज्ञात पैरामीटर `{key}`; क्या आपको `{near}` का मतलब है? @msg unknown parameter `{key}`; the command takes no parameters अज्ञात पैरामीटर `{key}`; कमांड कोई पैरामीटर नहीं लेता है @msg unknown parameter `{key}`; the command takes: {keys} अज्ञात पैरामीटर `{key}`; कमांड लेता है: {keys} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c3ed22bce8..d3d6e5f5e8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5594,6 +5594,7 @@ imageTrace Outline Körvonal @msg unknown option `{path}.{k}` ismeretlen beállítás: `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) ismeretlen felülnyomás-kezelés: `{o}` (preserve, simulate vagy discard) @msg unknown panel `{raw}` ismeretlen panel: `{raw}` +@msg panels can't be shown in Presentation Mode bemutató módban a panelek nem jeleníthetők meg @msg unknown preference `{key}` ismeretlen beállítás: `{key}` @msg Preferences imported. A beállítások importálva. @msg not a preferences file: {e} nem beállításfájl: {e} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a6edc31792..101ebc9145 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4832,6 +4832,7 @@ axis Both Entrambi @msg unknown option `{path}.{k}` opzione sconosciuta `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) valore di sovrastampa sconosciuto `{o}` (preserve, simulate o discard) @msg unknown panel `{raw}` pannello sconosciuto `{raw}` +@msg panels can't be shown in Presentation Mode i pannelli non possono essere mostrati in modalità presentazione @msg unknown preference `{key}` preferenza sconosciuta `{key}` @msg Preferences imported. Preferenze importate. @msg not a preferences file: {e} non è un file di preferenze: {e} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 65d7721aa1..42d77f68ad 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4861,6 +4861,7 @@ swatch Bright Violet ビビッドバイオレット @msg give only one of onto, below and above onto、below、above のうち 1 つだけを指定してください @msg column must be true or a number from 1 column には true または 1 以上の数値を指定してください @msg unknown panel `{raw}` `{raw}` は不明なパネルです +@msg panels can't be shown in Presentation Mode プレゼンテーションモードではパネルを表示できません @msg x and y must both be numbers x と y はどちらも数値で指定してください @msg Downloaded {what} {what} をダウンロードしました @msg Imported {what}: {_1} {what} を読み込みました: {_1} diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index eb686a14a0..76e439f180 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4526,6 +4526,7 @@ font-technology Variable ცვლადი @msg unknown option `{path}.{k}` უცნობი პარამეტრი `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) უცნობი ზედდაბეჭდვა `{o}` (preserve, simulate ან discard) @msg unknown panel `{raw}` უცნობი პანელი `{raw}` +@msg panels can't be shown in Presentation Mode პრეზენტაციის რეჟიმში პანელების ჩვენება შეუძლებელია @msg unknown preference `{key}` უცნობი პარამეტრი `{key}` @msg Preferences imported. პარამეტრები იმპორტირებულია. @msg not a preferences file: {e} ეს არ არის პარამეტრების ფაილი: {e} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 52a10ab8e8..5ea2f4412c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4788,6 +4788,7 @@ theme Appearance 외관 @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 panels can't be shown in Presentation Mode 프레젠테이션 모드에서는 패널을 표시할 수 없습니다 @msg x and y must both be numbers x와 y는 모두 숫자여야 합니다 @msg Downloaded {what} {what}을(를) 다운로드했습니다 @msg Imported {what}: {_1} {what}을(를) 가져왔습니다: {_1} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e44328480f..f2f185b826 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4915,6 +4915,7 @@ imageTrace Outline Kontur @msg unknown option `{path}.{k}` nieznana opcja `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) nieznana wartość nadruków `{o}` (preserve, simulate lub discard) @msg unknown panel `{raw}` nieznany panel `{raw}` +@msg panels can't be shown in Presentation Mode w trybie prezentacji nie można wyświetlić paneli @msg unknown preference `{key}` nieznana preferencja `{key}` @msg Preferences imported. Zaimportowano preferencje. @msg not a preferences file: {e} to nie jest plik preferencji: {e} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3ce111f7a4..8392d9a65c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4318,6 +4318,7 @@ axis Both Ambos @msg unknown option `{path}.{k}` opção desconhecida `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) sobreimpressões desconhecidas `{o}` (preserve, simulate ou discard) @msg unknown panel `{raw}` painel desconhecido `{raw}` +@msg panels can't be shown in Presentation Mode os painéis não podem ser mostrados no Modo de Apresentação @msg unknown preference `{key}` preferência desconhecida `{key}` @msg Preferences imported. Preferências importadas. @msg not a preferences file: {e} não é um arquivo de preferências: {e} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c93807c08a..f1d0376e06 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4818,6 +4818,7 @@ axis Both Оба @msg unknown option `{path}.{k}` неизвестный параметр `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) неизвестные наложения `{o}` (preserve, simulate или discard) @msg unknown panel `{raw}` неизвестная панель `{raw}` +@msg panels can't be shown in Presentation Mode в режиме презентации панели показать нельзя @msg unknown preference `{key}` неизвестная настройка `{key}` @msg Preferences imported. Настройки импортированы. @msg not a preferences file: {e} не является файлом настроек: {e} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ce20b6c72d..e176c12e78 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4678,6 +4678,7 @@ axis Both Båda @msg unknown option `{path}.{k}` okänt alternativ `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) okänt värde för övertryck `{o}` (preserve, simulate eller discard) @msg unknown panel `{raw}` okänd panel `{raw}` +@msg panels can't be shown in Presentation Mode paneler kan inte visas i presentationsläge @msg unknown preference `{key}` okänd inställning `{key}` @msg Preferences imported. Inställningarna har importerats. @msg not a preferences file: {e} inte en inställningsfil: {e} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index db3c4501b7..5ff5ee4daa 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4528,6 +4528,7 @@ axis Both Her İkisi @msg unknown option `{path}.{k}` bilinmeyen seçenek `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) bilinmeyen üst baskılar `{o}` (koru, benzet veya at) @msg unknown panel `{raw}` bilinmeyen panel `{raw}` +@msg panels can't be shown in Presentation Mode Sunum Modunda paneller gösterilemez @msg unknown preference `{key}` bilinmeyen tercih `{key}` @msg Preferences imported. Tercihler içe aktarıldı. @msg not a preferences file: {e} tercih dosyası değil: {e} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 151a886cf9..0b95dffcfe 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4816,6 +4816,7 @@ axis Both Обидві @msg unknown option `{path}.{k}` невідомий параметр `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) невідомі накладення `{o}` (preserve, simulate або discard) @msg unknown panel `{raw}` невідома панель `{raw}` +@msg panels can't be shown in Presentation Mode у режимі презентації панелі показати не можна @msg unknown preference `{key}` невідоме налаштування `{key}` @msg Preferences imported. Налаштування імпортовано. @msg not a preferences file: {e} не є файлом налаштувань: {e} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f3b505c648..dd53145f9b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5262,6 +5262,7 @@ theme Appearance Giao diện @msg unknown option `{path}.{k}` tùy chọn không xác định `{path}.{k}` @msg unknown overprints `{o}` (preserve, simulate or discard) chế độ in đè không xác định `{o}` (preserve, simulate hoặc discard) @msg unknown panel `{raw}` bảng không xác định `{raw}` +@msg panels can't be shown in Presentation Mode không thể hiển thị bảng trong chế độ trình bày @msg unknown parameter `{key}`; did you mean `{near}`? tham số không xác định `{key}`; ý bạn là `{near}`? @msg unknown parameter `{key}`; the command takes no parameters tham số không xác định `{key}`; lệnh này không nhận tham số @msg unknown parameter `{key}`; the command takes: {keys} tham số không xác định `{key}`; lệnh nhận: {keys} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c7219b9448..b8f4f5eccb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4556,6 +4556,7 @@ imageTrace Outline 轮廓 @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 panels can't be shown in Presentation Mode 演示模式下无法显示面板 @msg x and y must both be numbers x 和 y 都必须是数字 @msg Downloaded {what} 已下载 {what} @msg Imported {what}: {_1} 已导入 {what}:{_1} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1c753576a5..26e0f6fa38 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2349,6 +2349,7 @@ file-filter Templates 範本 @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 panels can't be shown in Presentation Mode 簡報模式下無法顯示面板 @msg x and y must both be numbers x 和 y 都必須是數字 @msg Downloaded {what} 已下載 {what} @msg Imported {what}: {_1} 已匯入 {what}:{_1} diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index d68608824b..be40dc44cb 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -96,7 +96,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { "window.learn" => crate::panels::learn::visible(&app.ui), "window.panel" => { let panel = p.get("panel").and_then(Value::as_str).unwrap_or(""); - normalize_panel(panel).is_some_and(|id| crate::panel_docking::showing(&app.ui, id)) + normalize_panel(panel).is_some_and(|id| crate::panel_docking::visible(&app.ui, id)) } "window.collapseDock" => crate::panel_docking::dock_collapsed(&app.ui), "window.workspace" => p.get("name").and_then(Value::as_str) == Some(app.ui.workspace.as_str()), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index f435bfff22..5aa591f114 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -595,7 +595,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.panel", "Show Panel", "", - "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that shows, and shows it again where it was", + "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that can be seen, and shows it again where it was; with the dock hidden by Tab or Shift+Tab, the dock shows again with the panel in front, leaving the toolbar and Control bar as they are; fails and changes nothing in Presentation Mode, which hides every panel", ), ( "window.panel.layout", diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index ef502761df..887c4cc83c 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -270,6 +270,12 @@ pub(crate) fn showing(ui: &UiState, panel: &str) -> bool { (ui.open_panel.as_deref() == Some(panel) && iconic) || (!iconic && layout.panel_state(&panel.to_string()) == craft_ui::docking::PanelState::Front) } +/// Can `panel` be seen: [`showing`] while the dock is shown (Tab and Shift+Tab hide it) and +/// Presentation Mode is off? The Window menu's check marks follow this. +pub(crate) fn visible(ui: &UiState, panel: &str) -> bool { + ui.dock && ui.screen_mode < 3 && showing(ui, panel) +} + /// Can either panel that uses row thumbnails be seen, including in a floating window or flyout? /// An iconic group's open flyout is named for the icon that opened it; its active tab can differ. fn thumbnail_panel_showing(ui: &UiState, ctx: &egui::Context) -> bool { @@ -343,19 +349,31 @@ pub(crate) fn command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Opt /// `window.panel {panel}`: show a panel. One in an icon column opens (or puts away) its flyout; /// one in front of its floating window hides; one in a group comes to the front; one closed with a remembered place comes back there; a -/// library panel or a panel the dock has no place for floats. +/// library panel or a panel the dock has no place for floats. A flyout or floating panel is put +/// away only while it can be seen ([`visible`]): with the dock hidden by Tab or Shift+Tab, the +/// dock shows again with the panel where it was, and the toolbar and Control bar stay as they are. +/// Presentation Mode hides every panel, so there the command fails and changes nothing. fn show_panel(app: &mut VectorcraftApp, params: &Value) -> Result { let raw = params.get("panel").and_then(Value::as_str).unwrap_or_default(); let panel = normalize(raw).ok_or_else(|| format!("unknown panel `{raw}`"))?; + if app.ui.screen_mode >= 3 { + return Err("panels can't be shown in Presentation Mode".into()); + } + place_panel(app, panel) +} + +/// [`show_panel`] for a known panel, in any screen mode. +fn place_panel(app: &mut VectorcraftApp, panel: &str) -> Result { + let was_visible = visible(&app.ui, panel); let layout = layout_of(&app.ui); app.ui.dock = true; if in_iconic_column(&layout, panel) { - app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(panel) { None } else { Some(panel.into()) }; + app.ui.open_panel = if was_visible { None } else { Some(panel.into()) }; return Ok(json!({"open": app.ui.open_panel})); } // A floating panel in front of its window hides again, as an icon panel's flyout does: its // shortcut shows and hides it. - if floating_window(&layout, panel).is_some() && layout.panel_state(&panel.to_string()) == craft_ui::docking::PanelState::Front { + if was_visible && floating_window(&layout, panel).is_some() { return shared_command(app, "window.panel.close", &json!({"panel": panel})); } if !layout.contains(&panel.to_string()) && !app.ui.docking_hidden.contains_key(panel) && crate::panels::library_panel::parse(panel).is_some() { @@ -404,14 +422,15 @@ fn restore_hidden_group(app: &mut VectorcraftApp, panel: &str) -> Result Result { - if showing(&app.ui, panel) { - app.ui.dock = true; + if visible(&app.ui, panel) { return Ok(json!({"open": panel})); } - show_panel(app, &json!({ "panel": panel })) + let id = normalize(panel).ok_or_else(|| format!("unknown panel `{panel}`"))?; + place_panel(app, id) } /// `window.collapseDock {collapsed?}`: collapse every dock column to icons, or expand the outer diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index 2d33420b9e..3e2cf999e8 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -346,6 +346,28 @@ fn a_character_field_shortcut_brings_the_floating_character_panel_forward() { } } +/// With the panels hidden by Tab, a Character field shortcut shows the dock with the floating +/// Character panel, so the field it focuses can be seen. +#[test] +fn a_character_field_shortcut_shows_the_panels_tab_hid() { + let mut h = Harness::new(); + let id = h.app.run("text.create", json!({"x": 20, "y": 50, "text": "AVAX"})).unwrap()["id"].clone(); + h.app.run("select.set", json!({"ids": [id]})).unwrap(); + h.app.run("window.panel.float", json!({"panel": "character"})).unwrap(); + h.frame(vec![]); + let rect = floating_window(&h.layout(), "character").map(|w| w.rect); + h.app.run("window.dock", json!({})).unwrap(); + assert!(!visible(&h.app.ui, "character")); + h.app.run("type.highlightKerning", json!({})).unwrap(); + assert!(h.app.ui.dock && visible(&h.app.ui, "character"), "the panel shows"); + assert_eq!(floating_window(&h.layout(), "character").map(|w| w.rect), rect, "where it was"); + // In Presentation Mode the shortcut still focuses the field, and the panel shows when the mode ends. + h.app.ui.screen_mode = 3; + assert_eq!(h.app.run("type.highlightTracking", json!({})).unwrap(), json!({"field": "tracking"})); + h.app.ui.screen_mode = 0; + assert!(visible(&h.app.ui, "character"), "the panel stays in place"); +} + #[test] fn the_dock_collapses_to_icons_and_back() { let mut h = Harness::new(); @@ -1112,6 +1134,144 @@ fn panel_function_keys_toggle_icon_flyouts_and_floating_groups() { assert!(!showing(&h.app.ui, "layers"), "the expanded dock also toggles"); } +/// Window › Layers: the menu item's command and params. +fn window_layers() -> Value { + json!({"panel": "layers", "toggle": true}) +} + +fn checked(h: &Harness, params: &Value) -> Option { + crate::menus::checked(&h.app, "window.panel", params) +} + +/// With the panels hidden by Tab, Window › Layers has no check mark, and choosing it shows the dock +/// with Layers where it was instead of closing the tab; the toolbar and the Control bar stay hidden +/// until the next Tab shows them. +#[test] +fn window_panel_from_tab_hidden_dock_reveals_named_front_tab() { + let mut h = Harness::new(); + let item = window_layers(); + h.app.run("window.panel", json!({"panel": "layers"})).unwrap(); + assert_eq!(checked(&h, &item), Some(true)); + let before = h.layout(); + h.app.ui.toolbar = true; + h.app.ui.control_bar = true; + h.app.run("window.dock", json!({})).unwrap(); + assert!(!h.app.ui.dock && !h.app.ui.toolbar && !h.app.ui.control_bar); + assert_eq!(checked(&h, &item), Some(false), "no check mark while Tab hides the panels"); + h.app.run("window.panel", item.clone()).unwrap(); + assert!(h.app.ui.dock && visible(&h.app.ui, "layers"), "Layers shows"); + assert_eq!(h.layout(), before, "in its place"); + assert!(!h.app.ui.toolbar && !h.app.ui.control_bar, "the toolbar and the Control bar stay hidden"); + assert_eq!(checked(&h, &item), Some(true)); + h.app.run("window.dock", json!({})).unwrap(); + assert!(h.app.ui.dock && h.app.ui.toolbar && h.app.ui.control_bar, "the next Tab shows the bars the first Tab hid"); + h.app.run("window.panel", item.clone()).unwrap(); + assert!(!h.layout().contains(&"layers".to_string()), "a visible panel closes"); + assert!(h.layout().contains(&"properties".to_string()), "only the panel named"); + assert_eq!(checked(&h, &item), Some(false)); +} + +/// With the panels hidden by Shift+Tab, Window › Layers and its plain command show a floating +/// Layers panel again where it was instead of closing it. +#[test] +fn window_panel_from_shift_tab_hidden_floating_window_reveals_it() { + let mut h = Harness::new(); + let item = window_layers(); + h.app.run("window.panel.float", json!({"panel": "layers"})).unwrap(); + h.frame(vec![]); + let rect = floating_window(&h.layout(), "layers").map(|w| w.rect); + assert!(rect.is_some()); + h.app.ui.toolbar = true; + h.app.ui.control_bar = true; + for params in [item.clone(), json!({"panel": "layers"})] { + h.app.run("window.panels", json!({})).unwrap(); + assert!(!h.app.ui.dock && h.app.ui.toolbar && h.app.ui.control_bar); + assert_eq!(checked(&h, &item), Some(false), "{params}: no check mark while Shift+Tab hides the panels"); + h.app.run("window.panel", params.clone()).unwrap(); + assert!(h.app.ui.dock && visible(&h.app.ui, "layers"), "{params}: Layers shows"); + assert_eq!(floating_window(&h.layout(), "layers").map(|w| w.rect), rect, "{params}: where it was"); + } +} + +/// While the dock is hidden, Window › Layers opens a flyout that was open in the icon column +/// instead of putting it away; a closed panel comes back and a background tab comes to the front. +/// With the dock showing, choosing the checked item again hides only that panel. +#[test] +fn window_panel_hidden_iconic_and_closed_cases() { + let mut h = Harness::new(); + let item = window_layers(); + h.app.run("window.collapseDock", json!({"collapsed": true})).unwrap(); + h.app.run("window.panel", item.clone()).unwrap(); + assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers")); + assert_eq!(checked(&h, &item), Some(true)); + h.app.ui.dock = false; + assert_eq!(checked(&h, &item), Some(false), "no check mark with the dock hidden"); + h.app.run("window.panel", item.clone()).unwrap(); + assert!(h.app.ui.dock, "the dock shows"); + assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"), "the flyout stays open"); + assert_eq!(checked(&h, &item), Some(true)); + h.app.run("window.panel", item.clone()).unwrap(); + assert_eq!(h.app.ui.open_panel, None, "a visible flyout puts away"); + + let mut h = Harness::new(); + let libraries = json!({"panel": "libraries", "toggle": true}); + h.app.run("window.panel.close", json!({"panel": "libraries"})).unwrap(); + h.app.run("window.panel.activate", json!({"panel": "properties"})).unwrap(); + h.app.run("window.dock", json!({})).unwrap(); + assert_eq!((checked(&h, &libraries), checked(&h, &item)), (Some(false), Some(false))); + h.app.run("window.panel", libraries.clone()).unwrap(); + assert!(h.app.ui.dock && visible(&h.app.ui, "libraries"), "a closed panel comes back"); + h.app.run("window.panel", item.clone()).unwrap(); + assert!(visible(&h.app.ui, "layers") && !visible(&h.app.ui, "properties"), "a background tab comes to the front"); + assert_eq!((checked(&h, &libraries), checked(&h, &item)), (Some(false), Some(true)), "the tab in front is checked"); +} + +/// F7 with the panels hidden by Tab shows the dock with the Layers group instead of hiding the +/// group; the next F7 hides it. +#[test] +fn function_key_from_tab_hidden_dock_reveals_named_group() { + let mut h = Harness::new(); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(visible(&h.app.ui, "layers")); + let before = h.layout(); + h.app.run("window.dock", json!({})).unwrap(); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(h.app.ui.dock && visible(&h.app.ui, "layers"), "F7 shows the hidden group"); + assert_eq!(h.layout(), before); + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert!(["properties", "layers", "libraries"].iter().all(|panel| !h.layout().contains(&panel.to_string())), "the next press hides the group"); +} + +/// Presentation Mode hides every panel: no panel is checked, `window.panel` fails and changes +/// nothing, and F7 is ignored. The other full screen modes show the panels as Normal does. +#[test] +fn presentation_panel_command_reports_unavailable_without_mutating_layout() { + let mut h = Harness::new(); + let item = window_layers(); + h.app.run("window.panel", json!({"panel": "layers"})).unwrap(); + h.app.run("window.panel.close", json!({"panel": "swatches"})).unwrap(); + h.app.run("view.presentation", json!({})).unwrap(); + assert_eq!(h.app.ui.screen_mode, 3); + assert_eq!(checked(&h, &item), Some(false), "no check mark in Presentation Mode"); + let before = (h.layout(), h.app.ui.dock, h.app.ui.open_panel.clone(), h.app.ui.docking_hidden.len()); + for params in [item.clone(), json!({"panel": "layers"}), json!({"panel": "swatches"}), json!({"panel": "brushes"})] { + let error = h.app.run("window.panel", params.clone()).unwrap_err(); + assert_eq!(error, "panels can't be shown in Presentation Mode", "{params}"); + assert_eq!((h.layout(), h.app.ui.dock, h.app.ui.open_panel.clone(), h.app.ui.docking_hidden.len()), before, "{params} changed nothing"); + } + h.press(egui::Key::F7, egui::Modifiers::NONE); + assert_eq!(h.app.ui.screen_mode, 3); + assert_eq!(h.layout(), before.0, "F7 is ignored"); + for mode in [1, 2] { + h.app.run("view.screenMode", json!({"mode": mode})).unwrap(); + assert_eq!(checked(&h, &item), Some(true), "mode {mode}"); + h.app.run("window.panel", item.clone()).unwrap(); + assert!(!visible(&h.app.ui, "layers"), "mode {mode}: the checked item hides the panel"); + h.app.run("window.panel", item.clone()).unwrap(); + assert!(visible(&h.app.ui, "layers"), "mode {mode}: and shows it again"); + } +} + #[test] fn other_function_keys_and_keyboard_guards_keep_their_routes() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 2f834a272c..adf6cacf3b 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -450,10 +450,10 @@ impl Field { /// Type tool edits, the panel shows, nothing takes the focus and the command fails. → `{field}`. pub(crate) fn highlight(app: &mut VectorcraftApp, field: Field) -> Result { // `window.panel` would hide a floating panel in front of its window, or an open flyout. - if crate::panel_docking::showing(&app.ui, "character") { + if crate::panel_docking::visible(&app.ui, "character") { app.run("window.panel.activate", json!({"panel": "character"}))?; } else { - app.run("window.panel", json!({"panel": "character"}))?; + crate::panel_docking::reveal_panel(app, "character")?; } if text_style(app).is_none() { app.character_focus = None; diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6a90cdfac0..1b5c381fe2 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -416,8 +416,7 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result Ok(json!({ "open": tool })), - "gradient" | "magicWand" => app.run("window.panel", json!({ "panel": tool })), + "gradient" | "magicWand" => crate::panel_docking::reveal_panel(app, tool), "artboard" => crate::dialogs::artboard_options::open(app), "eyedropper" => { crate::dialogs::eyedropper::open(app); diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs index e8e5d8f12c..102979182c 100644 --- a/crates/ui-egui/src/toolbar/tests.rs +++ b/crates/ui-egui/src/toolbar/tests.rs @@ -155,6 +155,15 @@ fn more_tools_open_their_options() { assert!(crate::panel_docking::showing(&app.ui, "magicWand")); open_options(&mut app, "magicWand").unwrap(); assert!(crate::panel_docking::showing(&app.ui, "magicWand")); + // With the panels hidden by Tab, the double-click shows them with the panel. + app.run("window.dock", json!({})).unwrap(); + open_options(&mut app, "magicWand").unwrap(); + assert!(app.ui.dock && crate::panel_docking::visible(&app.ui, "magicWand"), "the Magic Wand panel shows"); + // In Presentation Mode the double-click does not fail, and the panel shows when the mode ends. + app.ui.screen_mode = 3; + open_options(&mut app, "magicWand").unwrap(); + app.ui.screen_mode = 0; + assert!(crate::panel_docking::visible(&app.ui, "magicWand"), "the Magic Wand panel stays in place"); } /// A double-click at `at`, `time` seconds in (a second apart from the last). diff --git a/docs/mcp.md b/docs/mcp.md index 4ac7a10930..be1119ee69 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1381,6 +1381,11 @@ key, the Batch dialog or `ui.action.play`), a key a command no longer reads, rec and the action still plays. `Session::replay` plays a journal the same way. The command still refuses an effect `color` or `channel` it can't read. +`window.panel` refuses every request in Presentation Mode, which hides every panel, and changes nothing. With +`toggle` it hides only a panel that can be seen, which is the panel the Window menu checks. While Tab or Shift+Tab +hides the dock, no panel is checked, and the panel shows again with the dock in its earlier place; the toolbar and the +Control bar stay as they are. + The export commands limit those keys. `document.export`, `document.exportSelection`, `document.serialize`, `app.export` and `file.export` refuse a key that no format takes, and ignore a key that only another format takes (`{"format": "pdf", "quality": 80}` writes the PDF). `document.exportPdf`, `document.pdfSettings` and `pdf.preset.save` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3cc8df2a9e..411e07e889 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -890,6 +890,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields, one press each — a numeric field's scrub label is not a tab stop of its own, and the Transform panel's, Properties panel's and Control bar's W/H link comes after the fields it sits between (`one_tab_moves_from_a_field_to_the_next`). A field's up/down spinner, the chevron that opens its presets (Stroke Weight, font size, Rotate, Export for Screens' scale) and the Transparency row's opacity slider chevron are not tab stops either. Alt+↓ with no other modifier in a field with presets opens them with the keyboard focus on the chosen one, instead of stepping the field down; Enter picks the focused preset without pressing a dialog's OK, and after a pick or Escape the field has the keyboard focus again (`tab_goes_from_field_to_field_past_the_spinner_and_the_preset_chevron`, `tab_goes_from_the_opacity_field_past_its_slider_chevron`, `alt_down_opens_a_fields_presets_for_the_keyboard`, `escape_closes_a_fields_presets_and_returns_to_the_field`). - Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column. - The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown. + - While Tab or Shift+Tab hides the panels, the Window menu checks no panel, and choosing a panel there, or pressing its function key, shows the dock with that panel in its earlier place instead of closing it; the toolbar and the Control bar stay as they are. In Presentation Mode no panel is checked, and `window.panel` fails and changes nothing. A tool double-click that opens the Gradient or Magic Wand panel and a Character field shortcut also show the hidden dock (`window_panel_from_tab_hidden_dock_reveals_named_front_tab`, `window_panel_from_shift_tab_hidden_floating_window_reveals_it`, `function_key_from_tab_hidden_dock_reveals_named_group`, `presentation_panel_command_reports_unavailable_without_mutating_layout`). - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group. - On Windows and Linux, F2, F3 and F4 run Cut, Copy and Paste alongside Ctrl+X, Ctrl+C and Ctrl+V, and in the web build they do so everywhere except macOS. The Edit menu keeps the Ctrl shortcuts, and Keyboard Shortcuts lists both keys of each row. On macOS only Cmd+X, Cmd+C and Cmd+V are the defaults. F2, F3 and F4 do not run these commands in a focused text field or while the Type tool edits text. A saved shortcut set that assigns one of these keys to another command keeps that assignment. Public reference: [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html). - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). From 1db5b2ce531479c9b10089d6ac24d923f92e9019 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:32:03 -0800 Subject: [PATCH 08/34] i18n: plug-in host errors are translated in every complete catalog The plug-in host's manifest, module and run errors, the errors about an importer's, exporter's or effect's output and the approval error had no @msg row in most complete-message catalogs, and the reasons its error helpers put inside them (bad(), wrong(), out_err(), in_range(), unit(), read_packed()) were never scanned, so the status bar showed them in English or mixed two languages. - Every complete-message catalog gets the rows in a "Plug-in host messages" section, and the Hindi rows for these messages are fixed. - The message scan reads the plug-in host's error helpers, and objects.rs writes its two duplicate-id errors as whole strings. - message() leaves a captured JSON object or array as written. - Templates that start with text match before templates that start with a placeholder, so a wrapping message uses its own row. --- crates/plugins/src/objects.rs | 7 +- crates/ui-egui/src/i18n.rs | 6 +- crates/ui-egui/src/i18n/ca.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/catalog.rs | 6 +- crates/ui-egui/src/i18n/cs.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/de.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/el.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/fi.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/fr.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/hi.tsv | 135 +++++++++++++++----- crates/ui-egui/src/i18n/hu.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/it.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/ja.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/ka.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/ko.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/pl.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/pt-br.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/ru.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/sv.tsv | 68 ++++++++++ crates/ui-egui/src/i18n/tests.rs | 186 ++++++++++++++++++++++++++-- crates/ui-egui/src/i18n/tr.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/uk.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/vi.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/zh-hans.tsv | 104 ++++++++++++++++ crates/ui-egui/src/i18n/zh-hant.tsv | 104 ++++++++++++++++ docs/target-app-parity.md | 1 + 27 files changed, 2439 insertions(+), 50 deletions(-) diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 0e149579f5..d2a3c94ccc 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -1243,8 +1243,11 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec, match o.id { Some(id) => { let mut n = originals.remove(&id).ok_or_else(|| { - let why = if wanted.contains(&id) { "is listed twice" } else { "is not the id of an input object" }; - out_err(&at, format!("{} {why}", id.0)) + if wanted.contains(&id) { + out_err(&at, format!("{} is listed twice", id.0)) + } else { + out_err(&at, format!("{} is not the id of an input object", id.0)) + } })?; // Type, images and symbol instances are only read: writing one would make it a path. if input_kind(&n).is_some() && !o.kept { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 251dc585e2..7ee09ee14e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -658,7 +658,8 @@ pub fn label_or_name(lang: Lang, s: &str, built_in: bool) -> &str { const MESSAGE_DEPTH: usize = 5; /// Translate a status or error message for display (see the module docs). Unknown messages, and -/// parts of them (file names, numbers, a reason with no entry), stay as they are. +/// parts of them (file names, numbers, a reason with no entry, a JSON object or array such as +/// `{"objects": [...]}`), stay as they are. pub fn message(lang: Lang, s: &str) -> String { message_at(lang, s, MESSAGE_DEPTH) } @@ -668,8 +669,9 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String { let count = caps.iter().find(|(k, _)| *k == "n").and_then(|(_, v)| v.parse::().ok()); let form = count.map_or(forms.len().saturating_sub(1), |n| (lang.0.plural)(n).min(forms.len().saturating_sub(1))); let Some(template) = forms.get(form) else { return s.to_string() }; + let nested = |v: &str| depth > 1 && !v.starts_with(['{', '[']); let values: Vec<(&str, String)> = - caps.into_iter().map(|(k, v)| (k, if depth > 1 { message_at(lang, v, depth - 1) } else { v.to_string() })).collect(); + caps.into_iter().map(|(k, v)| (k, if nested(v) { message_at(lang, v, depth - 1) } else { v.to_string() })).collect(); let args: Vec<(&str, &str)> = values.iter().map(|(k, v)| (*k, v.as_str())).collect(); fill_once(template, &args) } diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cd6dfad587..27430527ec 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6152,6 +6152,110 @@ digits Malayalam Malaiàlam @msg {status} ({e}) {status} ({e}) @msg {tool} isn't in a toolbar group of several tools {tool} no forma part d’un grup de diverses eines a la barra d’eines @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants «{_1}» demana un pressupost elevat (cada execució fins a {minutes} minuts i {times} vegades les instruccions habituals): una persona l’ha d’aprovar a l’aplicació d’escriptori, o passa `allowHeavy: true` en una sessió iniciada amb --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` ha de tenir 1-64 caràcters imprimibles +@msg `version`, `description` or `author` is too long `version`, `description` o `author` és massa llarg +@msg `warnings` must be a list of strings `warnings` ha de ser una llista de cadenes +@msg `{k}` must be one of {_1} `{k}` ha de ser un de {_1} +@msg `{k}` must be {what} `{k}` ha de ser {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un exportador declara d’1 a {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` com a màxim {MAX_UNGROUPED} paràmetres fora de grups: doneu un `group` als altres +@msg can't encode the input no es pot codificar l’entrada +@msg expected {"dataBase64"} s’esperava {"dataBase64"} +@msg expected {"objects"} s’esperava {"objects"} +@msg export `{name}`: {e} exportació del mòdul `{name}`: {e} +@msg extension {e} is listed twice l’extensió {e} apareix dues vegades +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensió {e}: feu servir 1-16 lletres minúscules i dígits, sense el punt +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ID no vàlid {_1}: feu servir 1-64 caràcters d’A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} nom de paràmetre no vàlid {k} +@msg manifest is not UTF-8 el manifest no és UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) el manifest és massa llarg (límit {MAX_MANIFEST_BYTES} bytes) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) el manifest té {_1} bytes (límit {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) el mòdul té {_1} bytes (límit {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own cal aprovació: demana {0}, que l’usuari concedeix en instal·lar-lo per separat +@msg not JSON: {e} no és JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) no és un binari WebAssembly (falta la capçalera \0asm) +@msg parameter `{k}`: {what} paràmetre `{k}`: {what} +@msg parameter `{name}`: {what} paràmetre `{name}`: {what} +@msg plug-in parameters must be a JSON object els paràmetres del complement han de ser un objecte JSON +@msg the module must export its linear memory as `memory` el mòdul ha d’exportar la seva memòria lineal com a `memory` +@msg the objects to change are no longer in the document els objectes que calia canviar ja no són al document +@msg the runtime panicked l’entorn d’execució ha fallat +@msg unexpected host trap aturada inesperada (trap) de l’amfitrió +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version ha retornat {v}; aquest amfitrió implementa la versió {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc ha retornat 0 (sense memòria) +@msg vc_alloc returned a block outside memory vc_alloc ha retornat un bloc fora de la memòria +@msg vc_run returned error code {packed} vc_run ha retornat el codi d’error {packed} +@msg {_1} is not a file {_1} no és un fitxer +@msg {_1} is {_2} bytes (limit {_3}) {_1} té {_2} bytes (límit {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objectes (límit {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} apunta fora de la memòria +@msg `angle` must be a number `angle` ha de ser un nombre +@msg `aspect` must be from 0.01 to 100 `aspect` ha d’estar entre 0.01 i 100 +@msg `cap` must be "butt", "round" or "square" `cap` ha de ser "butt", "round" o "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` ha de ser "nonzero" o "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` ha de ser una adreça https:// de {MAX_HELP_URL} bytes com a màxim +@msg `help` must be 1-{MAX_HELP} printable characters `help` ha de tenir 1-{MAX_HELP} caràcters imprimibles +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` ha de ser un id d’efecte d’1-{MAX_EFFECT_ID} bytes +@msg `id` must be the id of an input object `id` ha de ser l’id d’un objecte d’entrada +@msg `join` must be "miter", "round" or "bevel" `join` ha de ser "miter", "round" o "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` i `group` han de tenir 1-{MAX_LABEL} caràcters imprimibles +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` ha de ser 1-{MAX_TEXT}, i el valor predeterminat no pot ser més llarg +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` i `maxItems` han de ser 0-{MAX_LIST}, `maxItems` com a mínim 1, i el valor predeterminat ha de tenir aquesta quantitat +@msg `name` must be a string `name` ha de ser una cadena +@msg `opacity` must be a number `opacity` ha de ser un nombre +@msg `type` must be "path" or "compound" `type` ha de ser "path" o "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` ha de tenir 1-{MAX_UNIT} caràcters imprimibles +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` ha d’indicar d’1 a {MAX_CONDITION_VALUES} valors per a `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` anomena `{other}`, que no és un paràmetre declarat abans d’aquest +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` anomena com a màxim {MAX_CONDITIONS} paràmetres +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` pren els valors de `{other}`, d’una casella de selecció o d’una opció +@msg `visible` must be true or false `visible` ha de ser true o false +@msg `{key}` must be a number from {_1} to {_2} `{key}` ha de ser un nombre de {_1} a {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` ha de ser una matriu de {MAX_ITEMS} entrades com a màxim +@msg a color default must be a color object or a hex string such as "#3a7bd5" un color predeterminat ha de ser un objecte de color o una cadena hexadecimal com ara "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" un objecte de color o una cadena hexadecimal com ara "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines un degradat de forma lliure conté com a màxim {MAX_ITEMS} punts i línies +@msg a freeform gradient line names a point it doesn't have una línia del degradat de forma lliure anomena un punt que el degradat no té +@msg a gradient needs 1-{MAX_ITEMS} stops un degradat necessita 1-{MAX_ITEMS} parades +@msg a list of numbers una llista de nombres +@msg a list of {min_items} to {max_items} numbers una llista de {min_items} a {max_items} nombres +@msg a new object needs a `path` un objecte nou necessita un `path` +@msg a number un nombre +@msg a painted live effect returns only `objects` un efecte dinàmic de pintura només retorna `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` una peça d’un efecte dinàmic de pintura només té `path`, `fillRule`, `fills` i `strokes` +@msg a read-only object was left out s’ha omès un objecte de només lectura +@msg a read-only object was left out: return it, as {"id"} alone or as it was given s’ha omès un objecte de només lectura: retorneu-lo com a {"id"} sol o tal com s’ha rebut +@msg a string una cadena +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un traç només té `paint`, `width`, `join`, `cap` i `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un traç ha de ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} un efecte ha de ser {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` un efecte en un farciment només retorna `fills` +@msg an effect on a stroke returns only `strokes` un efecte en un traç només retorna `strokes` +@msg an object must be a JSON object un objecte ha de ser un objecte JSON +@msg bad range interval no vàlid +@msg choices must be 1-64 non-empty, printable strings les opcions han de ser 1-64 cadenes imprimibles no buides +@msg coordinate out of range (limit ±{MAX_COORD} pt) coordenada fora de l’interval (límit ±{MAX_COORD} pt) +@msg default is not one of the options el valor predeterminat no és cap de les opcions +@msg each piece needs a `path` cada peça necessita un `path` +@msg is listed twice apareix dues vegades +@msg pattern transform out of range transformació del motiu fora de l’interval +@msg read-only field '{field}' changed ha canviat el camp de només lectura '{field}' +@msg stops must be in order of `offset` les parades han d’estar en ordre d’`offset` +@msg the document has no pattern {pattern} el document no té el motiu {pattern} +@msg the document has no swatch {n} el document no té la mostra {n} +@msg the output la sortida +@msg the path has no anchor points el traçat no té punts d’ancoratge +@msg too many anchor points (limit {MAX_ANCHORS}) massa punts d’ancoratge (límit {MAX_ANCHORS}) +@msg true or false true o false +@msg unknown key `{k}`; the keys here are {_1} clau desconeguda `{k}`; les claus aquí són {_1} +@msg {_1} is listed twice {_1} apareix dues vegades +@msg {_1} is not the id of an input object {_1} no és l’id d’un objecte d’entrada +@msg {_1} is read-only {_1} és de només lectura +@msg {what} must be from {lo} to {hi} {what} ha d’estar entre {lo} i {hi} +@msg a color component un component de color +@msg a plate una planxa # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Instal·la «{name}», que demana aquests permisos? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs index 8999157286..51259cb3d4 100644 --- a/crates/ui-egui/src/i18n/catalog.rs +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -188,8 +188,10 @@ impl Catalog { } } } - // Most literal text first, so `Couldn't open {name}: {e}` wins over `{path}: {e}`. - c.templates.sort_by_key(|t| std::cmp::Reverse(t.literal)); + // Templates that start with text before those that start with a placeholder, so + // `plug-in manifest: {0}` wins over `{_1} is {_2} bytes (limit {_3})` for a message it + // wraps; then most literal text first, so `Couldn't open {name}: {e}` wins over `{path}: {e}`. + c.templates.sort_by_key(|t| (matches!(t.pieces.first(), Some(Piece::Hole(_))), std::cmp::Reverse(t.literal))); c } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 00de3e7057..284035ea45 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6094,6 +6094,110 @@ axis Y Axis osa Y @msg a live effect runs while the document is drawn, so it can't be `heavy` živý efekt běží během vykreslování dokumentu, takže nemůže být `heavy` @msg an exporter always gets an export's budget: leave `budget` out exportní zásuvný modul dostává vždy rozpočet exportu: klíč `budget` vynechte @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}“ žádá o velký výpočetní rozpočet (každé spuštění až {minutes} minut a {times}krát víc instrukcí než obvykle): musí ho schválit člověk v desktopové aplikaci, nebo předejte `allowHeavy: true` v relaci spuštěné s --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` musí mít 1-64 tisknutelných znaků +@msg `version`, `description` or `author` is too long `version`, `description` nebo `author` je příliš dlouhé +@msg `warnings` must be a list of strings `warnings` musí být seznam řetězců +@msg `{k}` must be one of {_1} `{k}` musí být jedno z {_1} +@msg `{k}` must be {what} `{k}` musí být {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` exportní zásuvný modul deklaruje 1 až {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` mimo skupiny smí být nejvýše {MAX_UNGROUPED} parametrů: ostatním přiřaďte `group` +@msg can't encode the input vstup nelze zakódovat +@msg expected {"dataBase64"} očekáváno {"dataBase64"} +@msg expected {"objects"} očekáváno {"objects"} +@msg export `{name}`: {e} export `{name}`: {e} +@msg extension {e} is listed twice přípona {e} je uvedena dvakrát +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot přípona {e}: použijte 1-16 malých písmen a číslic, bez tečky +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - neplatné ID {_1}: použijte 1-64 znaků z A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} neplatný název parametru {k} +@msg manifest is not UTF-8 manifest není v kódování UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) manifest je příliš dlouhý (limit {MAX_MANIFEST_BYTES} bajtů) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) manifest má {_1} bajtů (limit {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) modul má {_1} bajtů (limit {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own vyžaduje schválení: žádá o {0}, což uživatel povolí, když ho instaluje samostatně +@msg not JSON: {e} není JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) není binární soubor WebAssembly (chybí hlavička \0asm) +@msg parameter `{k}`: {what} parametr `{k}`: {what} +@msg parameter `{name}`: {what} parametr `{name}`: {what} +@msg plug-in parameters must be a JSON object parametry zásuvného modulu musí být objekt JSON +@msg the module must export its linear memory as `memory` modul musí exportovat svou lineární paměť jako `memory` +@msg the objects to change are no longer in the document objekty ke změně už v dokumentu nejsou +@msg the runtime panicked běhové prostředí havarovalo +@msg unexpected host trap neočekávaná chyba (trap) hostitele +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version vrátila {v}; tento hostitel implementuje verzi {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc vrátila 0 (nedostatek paměti) +@msg vc_alloc returned a block outside memory vc_alloc vrátila blok mimo paměť +@msg vc_run returned error code {packed} vc_run vrátila chybový kód {packed} +@msg {_1} is not a file {_1} není soubor +@msg {_1} is {_2} bytes (limit {_3}) {_1} má {_2} bajtů (limit {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objektů (limit {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} ukazuje mimo paměť +@msg `angle` must be a number `angle` musí být číslo +@msg `aspect` must be from 0.01 to 100 `aspect` musí být od 0.01 do 100 +@msg `cap` must be "butt", "round" or "square" `cap` musí být "butt", "round" nebo "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` musí být "nonzero" nebo "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` musí být adresa https:// o nejvýše {MAX_HELP_URL} bajtech +@msg `help` must be 1-{MAX_HELP} printable characters `help` musí mít 1-{MAX_HELP} tisknutelných znaků +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` musí být ID efektu o 1-{MAX_EFFECT_ID} bajtech +@msg `id` must be the id of an input object `id` musí být ID vstupního objektu +@msg `join` must be "miter", "round" or "bevel" `join` musí být "miter", "round" nebo "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` a `group` musí mít 1-{MAX_LABEL} tisknutelných znaků +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` musí být 1-{MAX_TEXT} a výchozí hodnota nesmí být delší +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` a `maxItems` musí být 0-{MAX_LIST}, `maxItems` alespoň 1 a výchozí hodnota musí mít tolik položek +@msg `name` must be a string `name` musí být řetězec +@msg `opacity` must be a number `opacity` musí být číslo +@msg `type` must be "path" or "compound" `type` musí být "path" nebo "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` musí mít 1-{MAX_UNIT} tisknutelných znaků +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` musí uvádět 1 až {MAX_CONDITION_VALUES} hodnot pro `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` uvádí `{other}`, což není parametr deklarovaný před tímto +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` smí uvádět nejvýše {MAX_CONDITIONS} parametrů +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` přebírá hodnoty `{other}`, a to zaškrtávacího políčka nebo volby +@msg `visible` must be true or false `visible` musí být true nebo false +@msg `{key}` must be a number from {_1} to {_2} `{key}` musí být číslo od {_1} do {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` musí být pole s nejvýše {MAX_ITEMS} položkami +@msg a color default must be a color object or a hex string such as "#3a7bd5" výchozí barva musí být objekt barvy nebo šestnáctkový řetězec, například "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" objekt barvy nebo šestnáctkový řetězec, například "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines volný přechod obsahuje nejvýše {MAX_ITEMS} bodů a čar +@msg a freeform gradient line names a point it doesn't have čára volného přechodu uvádí bod, který přechod nemá +@msg a gradient needs 1-{MAX_ITEMS} stops přechod potřebuje 1-{MAX_ITEMS} zarážek +@msg a list of numbers seznam čísel +@msg a list of {min_items} to {max_items} numbers seznam {min_items} až {max_items} čísel +@msg a new object needs a `path` nový objekt potřebuje `path` +@msg a number číslo +@msg a painted live effect returns only `objects` malující živý efekt vrací jen `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` část malujícího živého efektu má jen `path`, `fillRule`, `fills` a `strokes` +@msg a read-only object was left out objekt jen pro čtení byl vynechán +@msg a read-only object was left out: return it, as {"id"} alone or as it was given objekt jen pro čtení byl vynechán: vraťte ho jako samotné {"id"} nebo tak, jak byl předán +@msg a string řetězec +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` tah má jen `paint`, `width`, `join`, `cap` a `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} tah musí být {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} efekt musí být {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` efekt na výplni vrací jen `fills` +@msg an effect on a stroke returns only `strokes` efekt na tahu vrací jen `strokes` +@msg an object must be a JSON object objekt musí být objekt JSON +@msg bad range neplatný rozsah +@msg choices must be 1-64 non-empty, printable strings volby musí být 1-64 neprázdných tisknutelných řetězců +@msg coordinate out of range (limit ±{MAX_COORD} pt) souřadnice mimo rozsah (limit ±{MAX_COORD} pt) +@msg default is not one of the options výchozí hodnota není jednou z možností +@msg each piece needs a `path` každá část potřebuje `path` +@msg is listed twice je uveden dvakrát +@msg pattern transform out of range transformace vzorku mimo rozsah +@msg read-only field '{field}' changed pole jen pro čtení '{field}' bylo změněno +@msg stops must be in order of `offset` zarážky musí být seřazeny podle `offset` +@msg the document has no pattern {pattern} dokument nemá vzorek {pattern} +@msg the document has no swatch {n} dokument nemá vzorek barvy {n} +@msg the output výstup +@msg the path has no anchor points cesta nemá žádné kotevní body +@msg too many anchor points (limit {MAX_ANCHORS}) příliš mnoho kotevních bodů (limit {MAX_ANCHORS}) +@msg true or false true nebo false +@msg unknown key `{k}`; the keys here are {_1} neznámý klíč `{k}`; zde platné klíče jsou {_1} +@msg {_1} is listed twice {_1} je uveden dvakrát +@msg {_1} is not the id of an input object {_1} není ID vstupního objektu +@msg {_1} is read-only {_1} je jen pro čtení +@msg {what} must be from {lo} to {hi} {what} musí být od {lo} do {hi} +@msg a color component složka barvy +@msg a plate deska # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Nainstalovat „{name}“, který žádá o tato oprávnění? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0de0dce0b9..38e0c9cfc5 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5685,6 +5685,110 @@ axis Y Axis Y-Achse @msg a live effect runs while the document is drawn, so it can't be `heavy` ein Live-Effekt läuft, während das Dokument gezeichnet wird, daher kann er nicht `heavy` sein @msg an exporter always gets an export's budget: leave `budget` out ein Export-Plug-in erhält immer das Budget eines Exports: Lassen Sie `budget` weg @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}“ verlangt ein großes Rechenbudget (jede Ausführung bis zu {minutes} Minuten und {times}-mal so viele Anweisungen wie üblich): Eine Person muss es in der Desktop-App genehmigen, oder übergeben Sie `allowHeavy: true` in einer mit --allow-plugin-grants gestarteten Sitzung +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` muss 1-64 druckbare Zeichen haben +@msg `version`, `description` or `author` is too long `version`, `description` oder `author` ist zu lang +@msg `warnings` must be a list of strings `warnings` muss eine Liste von Zeichenketten sein +@msg `{k}` must be one of {_1} `{k}` muss einer der folgenden Werte sein: {_1} +@msg `{k}` must be {what} `{k}` muss {what} sein +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ein Export-Plug-in gibt 1 bis {MAX_EXTENSIONS} `extensions` an +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` höchstens {MAX_UNGROUPED} Parameter außerhalb von Gruppen: Geben Sie den anderen eine `group` +@msg can't encode the input die Eingabe kann nicht codiert werden +@msg expected {"dataBase64"} erwartet: {"dataBase64"} +@msg expected {"objects"} erwartet: {"objects"} +@msg export `{name}`: {e} Modulexport `{name}`: {e} +@msg extension {e} is listed twice die Dateiendung {e} ist doppelt aufgeführt +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot Dateiendung {e}: Verwenden Sie 1-16 Kleinbuchstaben und Ziffern, ohne Punkt +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ungültige ID {_1}: Verwenden Sie 1-64 Zeichen aus A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} ungültiger Parametername {k} +@msg manifest is not UTF-8 das Manifest ist nicht in UTF-8 codiert +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) das Manifest ist zu lang (Grenze {MAX_MANIFEST_BYTES} Byte) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) das Manifest ist {_1} Byte groß (Grenze {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) das Modul ist {_1} Byte groß (Grenze {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own muss genehmigt werden: Es verlangt {0}, was der Benutzer gewährt, wenn er es einzeln installiert +@msg not JSON: {e} kein JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) keine WebAssembly-Binärdatei (der Header \0asm fehlt) +@msg parameter `{k}`: {what} Parameter `{k}`: {what} +@msg parameter `{name}`: {what} Parameter `{name}`: {what} +@msg plug-in parameters must be a JSON object die Plug-in-Parameter müssen ein JSON-Objekt sein +@msg the module must export its linear memory as `memory` das Modul muss seinen linearen Speicher als `memory` exportieren +@msg the objects to change are no longer in the document die zu ändernden Objekte sind nicht mehr im Dokument +@msg the runtime panicked die Laufzeitumgebung ist abgestürzt +@msg unexpected host trap unerwarteter Abbruch (Trap) im Host +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version hat {v} zurückgegeben; dieser Host unterstützt Version {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc hat 0 zurückgegeben (kein Speicher mehr) +@msg vc_alloc returned a block outside memory vc_alloc hat einen Block außerhalb des Speichers zurückgegeben +@msg vc_run returned error code {packed} vc_run hat den Fehlercode {packed} zurückgegeben +@msg {_1} is not a file {_1} ist keine Datei +@msg {_1} is {_2} bytes (limit {_3}) {_1} ist {_2} Byte groß (Grenze {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} Objekte (Grenze {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} zeigt außerhalb des Speichers +@msg `angle` must be a number `angle` muss eine Zahl sein +@msg `aspect` must be from 0.01 to 100 `aspect` muss zwischen 0.01 und 100 liegen +@msg `cap` must be "butt", "round" or "square" `cap` muss "butt", "round" oder "square" sein +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` muss "nonzero" oder "evenodd" sein +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` muss eine https://-Adresse mit höchstens {MAX_HELP_URL} Byte sein +@msg `help` must be 1-{MAX_HELP} printable characters `help` muss 1-{MAX_HELP} druckbare Zeichen haben +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` muss eine Effekt-ID mit 1-{MAX_EFFECT_ID} Byte sein +@msg `id` must be the id of an input object `id` muss die ID eines Eingabeobjekts sein +@msg `join` must be "miter", "round" or "bevel" `join` muss "miter", "round" oder "bevel" sein +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` und `group` müssen 1-{MAX_LABEL} druckbare Zeichen haben +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` muss 1-{MAX_TEXT} sein, und der Standardwert darf nicht länger sein +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` und `maxItems` müssen 0-{MAX_LIST} sein, `maxItems` mindestens 1, und der Standardwert muss so viele Einträge haben +@msg `name` must be a string `name` muss eine Zeichenkette sein +@msg `opacity` must be a number `opacity` muss eine Zahl sein +@msg `type` must be "path" or "compound" `type` muss "path" oder "compound" sein +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` muss 1-{MAX_UNIT} druckbare Zeichen haben +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` muss 1 bis {MAX_CONDITION_VALUES} Werte für `{other}` nennen +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` nennt `{other}`, aber das ist kein Parameter, der vor diesem deklariert ist +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` darf höchstens {MAX_CONDITIONS} Parameter nennen +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` nimmt die Werte von `{other}` an, die eines Kontrollkästchens oder einer Auswahl +@msg `visible` must be true or false `visible` muss true oder false sein +@msg `{key}` must be a number from {_1} to {_2} `{key}` muss eine Zahl von {_1} bis {_2} sein +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` muss ein Array mit höchstens {MAX_ITEMS} Einträgen sein +@msg a color default must be a color object or a hex string such as "#3a7bd5" ein Farb-Standardwert muss ein Farbobjekt oder ein Hex-String wie "#3a7bd5" sein +@msg a color object or a hex string such as "#3a7bd5" ein Farbobjekt oder ein Hex-String wie "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines ein Freiform-Verlauf enthält höchstens {MAX_ITEMS} Punkte und Linien +@msg a freeform gradient line names a point it doesn't have eine Linie des Freiform-Verlaufs nennt einen Punkt, den der Verlauf nicht hat +@msg a gradient needs 1-{MAX_ITEMS} stops ein Verlauf braucht 1-{MAX_ITEMS} Farbstopps +@msg a list of numbers eine Liste von Zahlen +@msg a list of {min_items} to {max_items} numbers eine Liste von {min_items} bis {max_items} Zahlen +@msg a new object needs a `path` ein neues Objekt braucht einen `path` +@msg a number eine Zahl +@msg a painted live effect returns only `objects` ein malender Live-Effekt gibt nur `objects` zurück +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ein Teil eines malenden Live-Effekts hat nur `path`, `fillRule`, `fills` und `strokes` +@msg a read-only object was left out ein schreibgeschütztes Objekt wurde weggelassen +@msg a read-only object was left out: return it, as {"id"} alone or as it was given ein schreibgeschütztes Objekt wurde weggelassen: Geben Sie es zurück, als {"id"} allein oder so, wie es übergeben wurde +@msg a string eine Zeichenkette +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` eine Kontur hat nur `paint`, `width`, `join`, `cap` und `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} eine Kontur muss {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} sein +@msg an effect must be {"id", "params"?, "visible"?} ein Effekt muss {"id", "params"?, "visible"?} sein +@msg an effect on a fill returns only `fills` ein Effekt auf einer Fläche gibt nur `fills` zurück +@msg an effect on a stroke returns only `strokes` ein Effekt auf einer Kontur gibt nur `strokes` zurück +@msg an object must be a JSON object ein Objekt muss ein JSON-Objekt sein +@msg bad range ungültiger Bereich +@msg choices must be 1-64 non-empty, printable strings die Auswahlwerte müssen 1-64 nicht leere, druckbare Zeichenketten sein +@msg coordinate out of range (limit ±{MAX_COORD} pt) Koordinate außerhalb des Bereichs (Grenze ±{MAX_COORD} pt) +@msg default is not one of the options der Standardwert ist keine der Optionen +@msg each piece needs a `path` jedes Teil braucht einen `path` +@msg is listed twice ist doppelt aufgeführt +@msg pattern transform out of range Mustertransformation außerhalb des Bereichs +@msg read-only field '{field}' changed das schreibgeschützte Feld '{field}' wurde geändert +@msg stops must be in order of `offset` die Farbstopps müssen nach `offset` geordnet sein +@msg the document has no pattern {pattern} das Dokument hat kein Muster {pattern} +@msg the document has no swatch {n} das Dokument hat kein Farbfeld {n} +@msg the output die Ausgabe +@msg the path has no anchor points der Pfad hat keine Ankerpunkte +@msg too many anchor points (limit {MAX_ANCHORS}) zu viele Ankerpunkte (Grenze {MAX_ANCHORS}) +@msg true or false true oder false +@msg unknown key `{k}`; the keys here are {_1} unbekannter Schlüssel `{k}`; die Schlüssel hier sind {_1} +@msg {_1} is listed twice {_1} ist doppelt aufgeführt +@msg {_1} is not the id of an input object {_1} ist nicht die ID eines Eingabeobjekts +@msg {_1} is read-only {_1} ist schreibgeschützt +@msg {what} must be from {lo} to {hi} {what} muss zwischen {lo} und {hi} liegen +@msg a color component eine Farbkomponente +@msg a plate eine Druckplatte # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? „{name}“ installieren, das diese Berechtigungen verlangt? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a75edff7a8..7d9db021eb 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6045,6 +6045,110 @@ axis Y Axis Άξονας Y @msg a live effect runs while the document is drawn, so it can't be `heavy` ένα δυναμικό εφέ εκτελείται κατά τη σχεδίαση του εγγράφου, οπότε δεν μπορεί να είναι `heavy` @msg an exporter always gets an export's budget: leave `budget` out ένα πρόσθετο εξαγωγής παίρνει πάντα τον προϋπολογισμό μιας εξαγωγής: παραλείψτε το `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants το «{_1}» ζητά μεγάλο υπολογιστικό προϋπολογισμό (κάθε εκτέλεση έως {minutes} λεπτά και {times} φορές τις συνήθεις εντολές): πρέπει να το εγκρίνει ένα άτομο στην εφαρμογή επιφάνειας εργασίας ή περάστε `allowHeavy: true` σε μια συνεδρία που ξεκίνησε με --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters το `name` πρέπει να έχει 1-64 εκτυπώσιμους χαρακτήρες +@msg `version`, `description` or `author` is too long το `version`, το `description` ή το `author` είναι πολύ μεγάλο +@msg `warnings` must be a list of strings το `warnings` πρέπει να είναι λίστα συμβολοσειρών +@msg `{k}` must be one of {_1} το `{k}` πρέπει να είναι ένα από τα {_1} +@msg `{k}` must be {what} το `{k}` πρέπει να είναι {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ένα πρόσθετο εξαγωγής δηλώνει 1 έως {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` το πολύ {MAX_UNGROUPED} παράμετροι εκτός ομάδων: δώστε στις υπόλοιπες ένα `group` +@msg can't encode the input η είσοδος δεν μπορεί να κωδικοποιηθεί +@msg expected {"dataBase64"} αναμενόταν {"dataBase64"} +@msg expected {"objects"} αναμενόταν {"objects"} +@msg export `{name}`: {e} εξαγωγή `{name}`: {e} +@msg extension {e} is listed twice η επέκταση {e} αναφέρεται δύο φορές +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot επέκταση {e}: χρησιμοποιήστε 1-16 πεζά γράμματα και ψηφία, χωρίς την τελεία +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - μη έγκυρο αναγνωριστικό {_1}: χρησιμοποιήστε 1-64 χαρακτήρες από A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} μη έγκυρο όνομα παραμέτρου {k} +@msg manifest is not UTF-8 το αρχείο δηλώσεων δεν είναι σε UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) το αρχείο δηλώσεων είναι πολύ μεγάλο (όριο {MAX_MANIFEST_BYTES} byte) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) το αρχείο δηλώσεων έχει {_1} byte (όριο {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) η λειτουργική μονάδα έχει {_1} byte (όριο {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own χρειάζεται έγκριση: ζητά {0}, που ο χρήστης παραχωρεί όταν το εγκαθιστά μόνο του +@msg not JSON: {e} δεν είναι JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) δεν είναι δυαδικό αρχείο WebAssembly (λείπει η κεφαλίδα \0asm) +@msg parameter `{k}`: {what} παράμετρος `{k}`: {what} +@msg parameter `{name}`: {what} παράμετρος `{name}`: {what} +@msg plug-in parameters must be a JSON object οι παράμετροι του προσθέτου πρέπει να είναι αντικείμενο JSON +@msg the module must export its linear memory as `memory` η λειτουργική μονάδα πρέπει να εξάγει τη γραμμική της μνήμη ως `memory` +@msg the objects to change are no longer in the document τα αντικείμενα προς αλλαγή δεν βρίσκονται πια στο έγγραφο +@msg the runtime panicked το περιβάλλον εκτέλεσης κατέρρευσε +@msg unexpected host trap μη αναμενόμενη παγίδευση του κεντρικού υπολογιστή +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} η vc_abi_version επέστρεψε {v}· αυτός ο κεντρικός υπολογιστής υλοποιεί την έκδοση {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) η vc_alloc επέστρεψε 0 (η μνήμη εξαντλήθηκε) +@msg vc_alloc returned a block outside memory η vc_alloc επέστρεψε ένα μπλοκ εκτός μνήμης +@msg vc_run returned error code {packed} η vc_run επέστρεψε τον κωδικό σφάλματος {packed} +@msg {_1} is not a file το {_1} δεν είναι αρχείο +@msg {_1} is {_2} bytes (limit {_3}) το {_1} έχει {_2} byte (όριο {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} αντικείμενα (όριο {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory το {what} δείχνει εκτός μνήμης +@msg `angle` must be a number το `angle` πρέπει να είναι αριθμός +@msg `aspect` must be from 0.01 to 100 το `aspect` πρέπει να είναι από 0.01 έως 100 +@msg `cap` must be "butt", "round" or "square" το `cap` πρέπει να είναι "butt", "round" ή "square" +@msg `fillRule` must be "nonzero" or "evenodd" το `fillRule` πρέπει να είναι "nonzero" ή "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes το `helpUrl` πρέπει να είναι διεύθυνση https:// έως {MAX_HELP_URL} byte +@msg `help` must be 1-{MAX_HELP} printable characters το `help` πρέπει να έχει 1-{MAX_HELP} εκτυπώσιμους χαρακτήρες +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes το `id` πρέπει να είναι αναγνωριστικό εφέ 1-{MAX_EFFECT_ID} byte +@msg `id` must be the id of an input object το `id` πρέπει να είναι το αναγνωριστικό ενός αντικειμένου εισόδου +@msg `join` must be "miter", "round" or "bevel" το `join` πρέπει να είναι "miter", "round" ή "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters τα `label` και `group` πρέπει να έχουν 1-{MAX_LABEL} εκτυπώσιμους χαρακτήρες +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer το `maxLength` πρέπει να είναι 1-{MAX_TEXT} και η προεπιλογή όχι μεγαλύτερη +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many τα `minItems` και `maxItems` πρέπει να είναι 0-{MAX_LIST}, το `maxItems` τουλάχιστον 1 και η προεπιλογή να έχει τόσα στοιχεία +@msg `name` must be a string το `name` πρέπει να είναι συμβολοσειρά +@msg `opacity` must be a number το `opacity` πρέπει να είναι αριθμός +@msg `type` must be "path" or "compound" το `type` πρέπει να είναι "path" ή "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters το `unit` πρέπει να έχει 1-{MAX_UNIT} εκτυπώσιμους χαρακτήρες +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` το `visibleWhen` πρέπει να αναφέρει 1 έως {MAX_CONDITION_VALUES} τιμές για το `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one το `visibleWhen` αναφέρει το `{other}`, που δεν είναι παράμετρος δηλωμένη πριν από αυτήν +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters το `visibleWhen` αναφέρει έως {MAX_CONDITIONS} παραμέτρους +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice το `visibleWhen` δέχεται τιμές του `{other}`, ενός πλαισίου ελέγχου ή μιας επιλογής +@msg `visible` must be true or false το `visible` πρέπει να είναι true ή false +@msg `{key}` must be a number from {_1} to {_2} το `{key}` πρέπει να είναι αριθμός από {_1} έως {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries το `{key}` πρέπει να είναι πίνακας έως {MAX_ITEMS} στοιχείων +@msg a color default must be a color object or a hex string such as "#3a7bd5" η προεπιλογή χρώματος πρέπει να είναι αντικείμενο χρώματος ή δεκαεξαδική συμβολοσειρά όπως "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" αντικείμενο χρώματος ή δεκαεξαδική συμβολοσειρά όπως "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines ένα ντεγκραντέ ελεύθερης μορφής έχει έως {MAX_ITEMS} σημεία και γραμμές +@msg a freeform gradient line names a point it doesn't have μια γραμμή ντεγκραντέ ελεύθερης μορφής αναφέρει σημείο που το ντεγκραντέ δεν έχει +@msg a gradient needs 1-{MAX_ITEMS} stops ένα ντεγκραντέ χρειάζεται 1-{MAX_ITEMS} σημεία χρώματος +@msg a list of numbers λίστα αριθμών +@msg a list of {min_items} to {max_items} numbers λίστα {min_items} έως {max_items} αριθμών +@msg a new object needs a `path` ένα νέο αντικείμενο χρειάζεται `path` +@msg a number αριθμός +@msg a painted live effect returns only `objects` ένα ζωντανό εφέ βαφής επιστρέφει μόνο `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ένα κομμάτι ζωντανού εφέ βαφής έχει μόνο `path`, `fillRule`, `fills` και `strokes` +@msg a read-only object was left out παραλείφθηκε ένα αντικείμενο μόνο για ανάγνωση +@msg a read-only object was left out: return it, as {"id"} alone or as it was given παραλείφθηκε ένα αντικείμενο μόνο για ανάγνωση: επιστρέψτε το ως {"id"} μόνο ή όπως δόθηκε +@msg a string συμβολοσειρά +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` ένα περίγραμμα έχει μόνο `paint`, `width`, `join`, `cap` και `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} ένα περίγραμμα πρέπει να είναι {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} ένα εφέ πρέπει να είναι {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` ένα εφέ σε γέμισμα επιστρέφει μόνο `fills` +@msg an effect on a stroke returns only `strokes` ένα εφέ σε περίγραμμα επιστρέφει μόνο `strokes` +@msg an object must be a JSON object ένα αντικείμενο πρέπει να είναι αντικείμενο JSON +@msg bad range μη έγκυρο εύρος +@msg choices must be 1-64 non-empty, printable strings οι επιλογές πρέπει να είναι 1-64 μη κενές, εκτυπώσιμες συμβολοσειρές +@msg coordinate out of range (limit ±{MAX_COORD} pt) συντεταγμένη εκτός εύρους (όριο ±{MAX_COORD} pt) +@msg default is not one of the options η προεπιλογή δεν είναι μία από τις επιλογές +@msg each piece needs a `path` κάθε κομμάτι χρειάζεται `path` +@msg is listed twice αναφέρεται δύο φορές +@msg pattern transform out of range μετασχηματισμός μοτίβου εκτός εύρους +@msg read-only field '{field}' changed το πεδίο μόνο για ανάγνωση '{field}' άλλαξε +@msg stops must be in order of `offset` τα σημεία χρώματος πρέπει να είναι ταξινομημένα κατά `offset` +@msg the document has no pattern {pattern} το έγγραφο δεν έχει μοτίβο {pattern} +@msg the document has no swatch {n} το έγγραφο δεν έχει δείγμα χρώματος {n} +@msg the output η έξοδος +@msg the path has no anchor points η διαδρομή δεν έχει σημεία αγκύρωσης +@msg too many anchor points (limit {MAX_ANCHORS}) πάρα πολλά σημεία αγκύρωσης (όριο {MAX_ANCHORS}) +@msg true or false true ή false +@msg unknown key `{k}`; the keys here are {_1} άγνωστο κλειδί `{k}`· τα κλειδιά εδώ είναι {_1} +@msg {_1} is listed twice το {_1} αναφέρεται δύο φορές +@msg {_1} is not the id of an input object το {_1} δεν είναι αναγνωριστικό αντικειμένου εισόδου +@msg {_1} is read-only το {_1} είναι μόνο για ανάγνωση +@msg {what} must be from {lo} to {hi} το {what} πρέπει να είναι από {lo} έως {hi} +@msg a color component συνιστώσα χρώματος +@msg a plate πλάκα εκτύπωσης # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Να εγκατασταθεί το «{name}», που ζητά αυτά τα δικαιώματα; ID: {id} Αναγνωριστικό: {id} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6d548f6743..b34cb08249 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6036,6 +6036,110 @@ axis Y Axis Eje Y @msg a live effect runs while the document is drawn, so it can't be `heavy` un efecto interactivo se ejecuta mientras se dibuja el documento, así que no puede ser `heavy` @msg an exporter always gets an export's budget: leave `budget` out un plugin de exportación siempre recibe el presupuesto de una exportación: omite `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” pide un presupuesto de cálculo grande (cada ejecución hasta {minutes} minutos y {times} veces las instrucciones habituales): una persona debe aprobarlo en la aplicación de escritorio, o pasa `allowHeavy: true` en una sesión iniciada con --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` debe tener 1-64 caracteres imprimibles +@msg `version`, `description` or `author` is too long `version`, `description` o `author` es demasiado largo +@msg `warnings` must be a list of strings `warnings` debe ser una lista de cadenas +@msg `{k}` must be one of {_1} `{k}` debe ser uno de estos: {_1} +@msg `{k}` must be {what} `{k}` debe ser {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un plugin de exportación declara de 1 a {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` como máximo {MAX_UNGROUPED} parámetros fuera de grupos: asigna un `group` a los demás +@msg can't encode the input no se puede codificar la entrada +@msg expected {"dataBase64"} se esperaba {"dataBase64"} +@msg expected {"objects"} se esperaba {"objects"} +@msg export `{name}`: {e} exportación del módulo `{name}`: {e} +@msg extension {e} is listed twice la extensión {e} aparece dos veces +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensión {e}: usa 1-16 letras minúsculas y dígitos, sin el punto +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ID no válido {_1}: usa 1-64 caracteres de A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} nombre de parámetro no válido {k} +@msg manifest is not UTF-8 el manifiesto no está en UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) el manifiesto es demasiado largo (límite {MAX_MANIFEST_BYTES} bytes) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) el manifiesto ocupa {_1} bytes (límite {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) el módulo ocupa {_1} bytes (límite {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own requiere aprobación: pide {0}, que el usuario concede al instalarlo por separado +@msg not JSON: {e} no es JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) no es un binario WebAssembly (falta el encabezado \0asm) +@msg parameter `{k}`: {what} parámetro `{k}`: {what} +@msg parameter `{name}`: {what} parámetro `{name}`: {what} +@msg plug-in parameters must be a JSON object los parámetros del plugin deben ser un objeto JSON +@msg the module must export its linear memory as `memory` el módulo debe exportar su memoria lineal como `memory` +@msg the objects to change are no longer in the document los objetos que se iban a cambiar ya no están en el documento +@msg the runtime panicked el entorno de ejecución falló +@msg unexpected host trap error inesperado (trap) del host +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version devolvió {v}; este host implementa la versión {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc devolvió 0 (sin memoria) +@msg vc_alloc returned a block outside memory vc_alloc devolvió un bloque fuera de la memoria +@msg vc_run returned error code {packed} vc_run devolvió el código de error {packed} +@msg {_1} is not a file {_1} no es un archivo +@msg {_1} is {_2} bytes (limit {_3}) {_1} ocupa {_2} bytes (límite {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objetos (límite {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} apunta fuera de la memoria +@msg `angle` must be a number `angle` debe ser un número +@msg `aspect` must be from 0.01 to 100 `aspect` debe estar entre 0.01 y 100 +@msg `cap` must be "butt", "round" or "square" `cap` debe ser "butt", "round" o "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` debe ser "nonzero" o "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` debe ser una dirección https:// de {MAX_HELP_URL} bytes como máximo +@msg `help` must be 1-{MAX_HELP} printable characters `help` debe tener 1-{MAX_HELP} caracteres imprimibles +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` debe ser un id de efecto de 1-{MAX_EFFECT_ID} bytes +@msg `id` must be the id of an input object `id` debe ser el id de un objeto de entrada +@msg `join` must be "miter", "round" or "bevel" `join` debe ser "miter", "round" o "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` y `group` deben tener 1-{MAX_LABEL} caracteres imprimibles +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` debe ser 1-{MAX_TEXT} y el valor predeterminado no puede ser más largo +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` y `maxItems` deben ser 0-{MAX_LIST}, `maxItems` al menos 1, y el valor predeterminado debe tener esa cantidad +@msg `name` must be a string `name` debe ser una cadena +@msg `opacity` must be a number `opacity` debe ser un número +@msg `type` must be "path" or "compound" `type` debe ser "path" o "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` debe tener 1-{MAX_UNIT} caracteres imprimibles +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` debe indicar de 1 a {MAX_CONDITION_VALUES} valores para `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` nombra `{other}`, que no es un parámetro declarado antes de este +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` nombra como máximo {MAX_CONDITIONS} parámetros +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` toma los valores de `{other}`, de una casilla de verificación o una opción +@msg `visible` must be true or false `visible` debe ser true o false +@msg `{key}` must be a number from {_1} to {_2} `{key}` debe ser un número de {_1} a {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` debe ser una lista de {MAX_ITEMS} entradas como máximo +@msg a color default must be a color object or a hex string such as "#3a7bd5" un color predeterminado debe ser un objeto de color o una cadena hexadecimal como "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" un objeto de color o una cadena hexadecimal como "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines un degradado de forma libre contiene como máximo {MAX_ITEMS} puntos y líneas +@msg a freeform gradient line names a point it doesn't have una línea del degradado de forma libre nombra un punto que el degradado no tiene +@msg a gradient needs 1-{MAX_ITEMS} stops un degradado necesita 1-{MAX_ITEMS} paradas +@msg a list of numbers una lista de números +@msg a list of {min_items} to {max_items} numbers una lista de {min_items} a {max_items} números +@msg a new object needs a `path` un objeto nuevo necesita un `path` +@msg a number un número +@msg a painted live effect returns only `objects` un efecto dinámico de pintura solo devuelve `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` una pieza de un efecto dinámico de pintura solo tiene `path`, `fillRule`, `fills` y `strokes` +@msg a read-only object was left out se omitió un objeto de solo lectura +@msg a read-only object was left out: return it, as {"id"} alone or as it was given se omitió un objeto de solo lectura: devuélvalo como {"id"} solo o tal como se recibió +@msg a string una cadena +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un trazo solo tiene `paint`, `width`, `join`, `cap` y `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un trazo debe ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} un efecto debe ser {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` un efecto en un relleno solo devuelve `fills` +@msg an effect on a stroke returns only `strokes` un efecto en un trazo solo devuelve `strokes` +@msg an object must be a JSON object un objeto debe ser un objeto JSON +@msg bad range rango no válido +@msg choices must be 1-64 non-empty, printable strings las opciones deben ser 1-64 cadenas imprimibles no vacías +@msg coordinate out of range (limit ±{MAX_COORD} pt) coordenada fuera de rango (límite ±{MAX_COORD} pt) +@msg default is not one of the options el valor predeterminado no es una de las opciones +@msg each piece needs a `path` cada pieza necesita un `path` +@msg is listed twice aparece dos veces +@msg pattern transform out of range transformación del motivo fuera de rango +@msg read-only field '{field}' changed cambió el campo de solo lectura '{field}' +@msg stops must be in order of `offset` las paradas deben estar en orden de `offset` +@msg the document has no pattern {pattern} el documento no tiene el motivo {pattern} +@msg the document has no swatch {n} el documento no tiene la muestra {n} +@msg the output la salida +@msg the path has no anchor points el trazado no tiene puntos de ancla +@msg too many anchor points (limit {MAX_ANCHORS}) demasiados puntos de ancla (límite {MAX_ANCHORS}) +@msg true or false true o false +@msg unknown key `{k}`; the keys here are {_1} clave desconocida `{k}`; las claves aquí son {_1} +@msg {_1} is listed twice {_1} aparece dos veces +@msg {_1} is not the id of an input object {_1} no es el id de un objeto de entrada +@msg {_1} is read-only {_1} es de solo lectura +@msg {what} must be from {lo} to {hi} {what} debe estar entre {lo} y {hi} +@msg a color component un componente de color +@msg a plate una plancha # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? ¿Instalar “{name}”, que pide estos permisos? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7ae54d2bef..6d78d991c7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6124,6 +6124,110 @@ axis Y Axis Y-akseli @msg a live effect runs while the document is drawn, so it can't be `heavy` live-tehoste suoritetaan asiakirjaa piirrettäessä, joten se ei voi olla `heavy` @msg an exporter always gets an export's budget: leave `budget` out vientilaajennus saa aina viennin budjetin: jätä `budget` pois @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants ”{_1}” pyytää suurta laskentabudjettia (jokainen ajo enintään {minutes} minuuttia ja {times} kertaa tavallinen määrä käskyjä): ihmisen on hyväksyttävä se työpöytäsovelluksessa, tai anna `allowHeavy: true` istunnossa, joka on käynnistetty valitsimella --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name`-kentässä on oltava 1-64 tulostettavaa merkkiä +@msg `version`, `description` or `author` is too long `version`, `description` tai `author` on liian pitkä +@msg `warnings` must be a list of strings `warnings` on oltava merkkijonojen luettelo +@msg `{k}` must be one of {_1} `{k}` on oltava jokin seuraavista: {_1} +@msg `{k}` must be {what} `{k}` on oltava {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` vientilaajennus ilmoittaa 1–{MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` ryhmien ulkopuolella saa olla enintään {MAX_UNGROUPED} parametria: anna muille `group` +@msg can't encode the input syötettä ei voi koodata +@msg expected {"dataBase64"} odotettiin {"dataBase64"} +@msg expected {"objects"} odotettiin {"objects"} +@msg export `{name}`: {e} moduulin vienti `{name}`: {e} +@msg extension {e} is listed twice tunniste {e} on luettelossa kahdesti +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot tunniste {e}: käytä 1-16 pientä kirjainta ja numeroa ilman pistettä +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - virheellinen tunnus {_1}: käytä 1-64 merkkiä joukosta A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} virheellinen parametrin nimi {k} +@msg manifest is not UTF-8 manifesti ei ole UTF-8-muodossa +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) manifesti on liian pitkä (raja {MAX_MANIFEST_BYTES} tavua) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) manifesti on {_1} tavua (raja {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) moduuli on {_1} tavua (raja {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own vaatii hyväksynnän: se pyytää {0}, jotka käyttäjä myöntää asentaessaan sen erikseen +@msg not JSON: {e} ei JSON-muotoa: {e} +@msg not a WebAssembly binary (missing \0asm header) ei WebAssembly-binääri (\0asm-otsake puuttuu) +@msg parameter `{k}`: {what} parametri `{k}`: {what} +@msg parameter `{name}`: {what} parametri `{name}`: {what} +@msg plug-in parameters must be a JSON object laajennuksen parametrien on oltava JSON-objekti +@msg the module must export its linear memory as `memory` moduulin on vietävä lineaarinen muistinsa nimellä `memory` +@msg the objects to change are no longer in the document muutettavia objekteja ei enää ole asiakirjassa +@msg the runtime panicked suoritusympäristö kaatui +@msg unexpected host trap odottamaton keskeytys (trap) isännässä +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version palautti {v}; tämä isäntä toteuttaa version {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc palautti 0 (muisti loppui) +@msg vc_alloc returned a block outside memory vc_alloc palautti lohkon muistin ulkopuolelta +@msg vc_run returned error code {packed} vc_run palautti virhekoodin {packed} +@msg {_1} is not a file {_1} ei ole tiedosto +@msg {_1} is {_2} bytes (limit {_3}) {_1} on {_2} tavua (raja {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objektia (raja {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} osoittaa muistin ulkopuolelle +@msg `angle` must be a number `angle` on oltava luku +@msg `aspect` must be from 0.01 to 100 `aspect` on oltava 0.01 ja 100 välillä +@msg `cap` must be "butt", "round" or "square" `cap` on oltava "butt", "round" tai "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` on oltava "nonzero" tai "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` on oltava enintään {MAX_HELP_URL} tavun https://-osoite +@msg `help` must be 1-{MAX_HELP} printable characters `help`-kentässä on oltava 1-{MAX_HELP} tulostettavaa merkkiä +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` on oltava 1-{MAX_EFFECT_ID} tavun tehostetunnus +@msg `id` must be the id of an input object `id` on oltava syöteobjektin tunnus +@msg `join` must be "miter", "round" or "bevel" `join` on oltava "miter", "round" tai "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label`- ja `group`-kentissä on oltava 1-{MAX_LABEL} tulostettavaa merkkiä +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` on oltava 1-{MAX_TEXT}, eikä oletusarvo saa olla pidempi +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` ja `maxItems` on oltava 0-{MAX_LIST}, `maxItems` vähintään 1, ja oletusarvossa on oltava niin monta kohdetta +@msg `name` must be a string `name` on oltava merkkijono +@msg `opacity` must be a number `opacity` on oltava luku +@msg `type` must be "path" or "compound" `type` on oltava "path" tai "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit`-kentässä on oltava 1-{MAX_UNIT} tulostettavaa merkkiä +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen`-kentässä on lueteltava 1-{MAX_CONDITION_VALUES} arvoa parametrille `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` nimeää parametrin `{other}`, jota ei ole esitelty ennen tätä parametria +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` nimeää enintään {MAX_CONDITIONS} parametria +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` ottaa parametrin `{other}` arvot, valintaruudun tai valinnan arvot +@msg `visible` must be true or false `visible` on oltava true tai false +@msg `{key}` must be a number from {_1} to {_2} `{key}` on oltava luku {_1} ja {_2} väliltä +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` on oltava enintään {MAX_ITEMS} kohteen taulukko +@msg a color default must be a color object or a hex string such as "#3a7bd5" värin oletusarvon on oltava väriobjekti tai heksamerkkijono, kuten "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" väriobjekti tai heksamerkkijono, kuten "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines vapaamuotoisessa liukuvärissä on enintään {MAX_ITEMS} pistettä ja viivaa +@msg a freeform gradient line names a point it doesn't have vapaamuotoisen liukuvärin viiva nimeää pisteen, jota liukuvärissä ei ole +@msg a gradient needs 1-{MAX_ITEMS} stops liukuväri tarvitsee 1-{MAX_ITEMS} väripistettä +@msg a list of numbers lukujen luettelo +@msg a list of {min_items} to {max_items} numbers {min_items}-{max_items} luvun luettelo +@msg a new object needs a `path` uusi objekti tarvitsee kentän `path` +@msg a number luku +@msg a painted live effect returns only `objects` maalaava live-tehoste palauttaa vain kentän `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` maalaavan live-tehosteen osalla on vain `path`, `fillRule`, `fills` ja `strokes` +@msg a read-only object was left out vain luku -objekti jätettiin pois +@msg a read-only object was left out: return it, as {"id"} alone or as it was given vain luku -objekti jätettiin pois: palauta se pelkkänä {"id"}-muotona tai sellaisena kuin se annettiin +@msg a string merkkijono +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` viivalla on vain `paint`, `width`, `join`, `cap` ja `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} viivan on oltava muotoa {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} tehosteen on oltava muotoa {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` täytön tehoste palauttaa vain kentän `fills` +@msg an effect on a stroke returns only `strokes` viivan tehoste palauttaa vain kentän `strokes` +@msg an object must be a JSON object objektin on oltava JSON-objekti +@msg bad range virheellinen alue +@msg choices must be 1-64 non-empty, printable strings vaihtoehtojen on oltava 1-64 ei-tyhjää, tulostettavaa merkkijonoa +@msg coordinate out of range (limit ±{MAX_COORD} pt) koordinaatti alueen ulkopuolella (raja ±{MAX_COORD} pt) +@msg default is not one of the options oletusarvo ei ole mikään vaihtoehdoista +@msg each piece needs a `path` jokainen osa tarvitsee kentän `path` +@msg is listed twice on luettelossa kahdesti +@msg pattern transform out of range kuvion muunnos alueen ulkopuolella +@msg read-only field '{field}' changed vain luku -kenttä '{field}' muuttui +@msg stops must be in order of `offset` väripisteiden on oltava `offset`-järjestyksessä +@msg the document has no pattern {pattern} asiakirjassa ei ole kuviota {pattern} +@msg the document has no swatch {n} asiakirjassa ei ole värimallia {n} +@msg the output tuloste +@msg the path has no anchor points polulla ei ole ankkuripisteitä +@msg too many anchor points (limit {MAX_ANCHORS}) liian monta ankkuripistettä (raja {MAX_ANCHORS}) +@msg true or false true tai false +@msg unknown key `{k}`; the keys here are {_1} tuntematon avain `{k}`; tässä käytettävät avaimet ovat {_1} +@msg {_1} is listed twice {_1} on luettelossa kahdesti +@msg {_1} is not the id of an input object {_1} ei ole syöteobjektin tunnus +@msg {_1} is read-only {_1} on vain luku -tilassa +@msg {what} must be from {lo} to {hi} {what}: arvon on oltava {lo} ja {hi} välillä +@msg a color component värikomponentti +@msg a plate painolevy # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Asennetaanko ”{name}”, joka pyytää näitä käyttöoikeuksia? ID: {id} Tunnus: {id} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 35c649f895..1141d030e4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6089,6 +6089,110 @@ axis Y Axis Axe Y @msg a live effect runs while the document is drawn, so it can't be `heavy` un effet dynamique s’exécute pendant le tracé du document, il ne peut donc pas être `heavy` @msg an exporter always gets an export's budget: leave `budget` out un module externe d’exportation reçoit toujours le budget d’une exportation : omettez `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants « {_1} » demande un budget de calcul important (chaque exécution jusqu’à {minutes} minutes et {times} fois les instructions habituelles) : une personne doit l’approuver dans l’application de bureau, ou passez `allowHeavy: true` dans une session démarrée avec --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` doit compter 1-64 caractères imprimables +@msg `version`, `description` or `author` is too long `version`, `description` ou `author` est trop long +@msg `warnings` must be a list of strings `warnings` doit être une liste de chaînes +@msg `{k}` must be one of {_1} `{k}` doit valoir l’une de ces valeurs : {_1} +@msg `{k}` must be {what} `{k}` doit être {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un module externe d’exportation déclare de 1 à {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` {MAX_UNGROUPED} paramètres au plus hors des groupes : donnez un `group` aux autres +@msg can't encode the input impossible d’encoder l’entrée +@msg expected {"dataBase64"} attendu : {"dataBase64"} +@msg expected {"objects"} attendu : {"objects"} +@msg export `{name}`: {e} export du module `{name}` : {e} +@msg extension {e} is listed twice l’extension {e} figure deux fois +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot extension {e} : utilisez 1-16 lettres minuscules et chiffres, sans le point +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ID non valide {_1} : utilisez 1-64 caractères parmi A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} nom de paramètre non valide {k} +@msg manifest is not UTF-8 le manifeste n’est pas en UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) le manifeste est trop long (limite {MAX_MANIFEST_BYTES} octets) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) le manifeste fait {_1} octets (limite {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) le module fait {_1} octets (limite {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own approbation requise : il demande {0}, que l’utilisateur accorde en l’installant seul +@msg not JSON: {e} pas du JSON : {e} +@msg not a WebAssembly binary (missing \0asm header) pas un binaire WebAssembly (en-tête \0asm absent) +@msg parameter `{k}`: {what} paramètre `{k}` : {what} +@msg parameter `{name}`: {what} paramètre `{name}` : {what} +@msg plug-in parameters must be a JSON object les paramètres du plug-in doivent être un objet JSON +@msg the module must export its linear memory as `memory` le module doit exporter sa mémoire linéaire sous le nom `memory` +@msg the objects to change are no longer in the document les objets à modifier ne sont plus dans le document +@msg the runtime panicked l’environnement d’exécution a planté +@msg unexpected host trap arrêt inattendu (trap) de l’hôte +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version a renvoyé {v} ; cet hôte implémente la version {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc a renvoyé 0 (mémoire épuisée) +@msg vc_alloc returned a block outside memory vc_alloc a renvoyé un bloc hors de la mémoire +@msg vc_run returned error code {packed} vc_run a renvoyé le code d’erreur {packed} +@msg {_1} is not a file {_1} n’est pas un fichier +@msg {_1} is {_2} bytes (limit {_3}) {_1} fait {_2} octets (limite {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objets (limite {MAX_OBJECTS}) +@msg {at}: {msg} {at} : {msg} +@msg {what} points outside memory {what} pointe hors de la mémoire +@msg `angle` must be a number `angle` doit être un nombre +@msg `aspect` must be from 0.01 to 100 `aspect` doit être compris entre 0.01 et 100 +@msg `cap` must be "butt", "round" or "square" `cap` doit valoir "butt", "round" ou "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` doit valoir "nonzero" ou "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` doit être une adresse https:// d’au plus {MAX_HELP_URL} octets +@msg `help` must be 1-{MAX_HELP} printable characters `help` doit compter 1-{MAX_HELP} caractères imprimables +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` doit être un identifiant d’effet de 1-{MAX_EFFECT_ID} octets +@msg `id` must be the id of an input object `id` doit être l’identifiant d’un objet d’entrée +@msg `join` must be "miter", "round" or "bevel" `join` doit valoir "miter", "round" ou "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` et `group` doivent compter 1-{MAX_LABEL} caractères imprimables +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` doit valoir 1-{MAX_TEXT}, et la valeur par défaut ne doit pas être plus longue +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` et `maxItems` doivent valoir 0-{MAX_LIST}, `maxItems` au moins 1, et la valeur par défaut doit en compter autant +@msg `name` must be a string `name` doit être une chaîne +@msg `opacity` must be a number `opacity` doit être un nombre +@msg `type` must be "path" or "compound" `type` doit valoir "path" ou "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` doit compter 1-{MAX_UNIT} caractères imprimables +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` doit indiquer 1 à {MAX_CONDITION_VALUES} valeurs pour `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` nomme `{other}`, qui n’est pas un paramètre déclaré avant celui-ci +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` nomme au plus {MAX_CONDITIONS} paramètres +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` prend les valeurs de `{other}`, celles d’une case à cocher ou d’un choix +@msg `visible` must be true or false `visible` doit valoir true ou false +@msg `{key}` must be a number from {_1} to {_2} `{key}` doit être un nombre de {_1} à {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` doit être un tableau d’au plus {MAX_ITEMS} entrées +@msg a color default must be a color object or a hex string such as "#3a7bd5" une couleur par défaut doit être un objet couleur ou une chaîne hexadécimale comme "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" un objet couleur ou une chaîne hexadécimale comme "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines un dégradé de forme libre contient au plus {MAX_ITEMS} points et lignes +@msg a freeform gradient line names a point it doesn't have une ligne du dégradé de forme libre nomme un point que le dégradé n’a pas +@msg a gradient needs 1-{MAX_ITEMS} stops un dégradé nécessite 1-{MAX_ITEMS} étapes +@msg a list of numbers une liste de nombres +@msg a list of {min_items} to {max_items} numbers une liste de {min_items} à {max_items} nombres +@msg a new object needs a `path` un nouvel objet nécessite un `path` +@msg a number un nombre +@msg a painted live effect returns only `objects` un effet dynamique de peinture renvoie seulement `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` une pièce d’un effet dynamique de peinture a seulement `path`, `fillRule`, `fills` et `strokes` +@msg a read-only object was left out un objet en lecture seule a été omis +@msg a read-only object was left out: return it, as {"id"} alone or as it was given un objet en lecture seule a été omis : renvoyez-le, sous la forme {"id"} seul ou tel qu’il a été fourni +@msg a string une chaîne +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un contour a seulement `paint`, `width`, `join`, `cap` et `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un contour doit être {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} un effet doit être {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` un effet sur un fond renvoie seulement `fills` +@msg an effect on a stroke returns only `strokes` un effet sur un contour renvoie seulement `strokes` +@msg an object must be a JSON object un objet doit être un objet JSON +@msg bad range plage non valide +@msg choices must be 1-64 non-empty, printable strings les choix doivent être 1-64 chaînes imprimables non vides +@msg coordinate out of range (limit ±{MAX_COORD} pt) coordonnée hors limites (limite ±{MAX_COORD} pt) +@msg default is not one of the options la valeur par défaut ne fait pas partie des options +@msg each piece needs a `path` chaque pièce nécessite un `path` +@msg is listed twice figure deux fois +@msg pattern transform out of range transformation du motif hors limites +@msg read-only field '{field}' changed le champ en lecture seule '{field}' a été modifié +@msg stops must be in order of `offset` les étapes doivent être classées par `offset` +@msg the document has no pattern {pattern} le document n’a pas de motif {pattern} +@msg the document has no swatch {n} le document n’a pas de nuance {n} +@msg the output la sortie +@msg the path has no anchor points le tracé n’a aucun point d’ancrage +@msg too many anchor points (limit {MAX_ANCHORS}) trop de points d’ancrage (limite {MAX_ANCHORS}) +@msg true or false true ou false +@msg unknown key `{k}`; the keys here are {_1} clé inconnue `{k}` ; les clés ici sont {_1} +@msg {_1} is listed twice {_1} figure deux fois +@msg {_1} is not the id of an input object {_1} n’est pas l’identifiant d’un objet d’entrée +@msg {_1} is read-only {_1} est en lecture seule +@msg {what} must be from {lo} to {hi} {what} doit être compris entre {lo} et {hi} +@msg a color component une composante de couleur +@msg a plate une plaque # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Installer « {name} », qui demande ces autorisations ? ID: {id} ID : {id} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 335b0c99f0..90fdb0e939 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5409,6 +5409,73 @@ antiAlias Sharp शार्प @id pathfinder.add मिलाएँ @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” अधिक संसाधन माँगता है (हर रन में {minutes} मिनट और सामान्य से {times} गुना निर्देश): इसे डेस्कटॉप ऐप में किसी व्यक्ति को स्वीकृत करना होगा, या --allow-plugin-grants से शुरू किए गए सत्र में `allowHeavy: true` दें @msg "{0}" is not a colour: write it as #rrggbb "{0}" एक रंग नहीं है: इसे #rrggbb के रूप में लिखें +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg needs approval: it asks for {0}, which the user grants when installing it on its own स्वीकृति आवश्यक: यह {0} माँगता है, जिसे उपयोगकर्ता इसे अलग से स्थापित करते समय देता है +@msg `angle` must be a number `angle` एक संख्या होना चाहिए +@msg `aspect` must be from 0.01 to 100 `aspect` 0.01 से 100 तक होना चाहिए +@msg `cap` must be "butt", "round" or "square" `cap` "butt", "round" या "square" होना चाहिए +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` "nonzero" या "evenodd" होना चाहिए +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` अधिकतम {MAX_HELP_URL} बाइट का https:// पता होना चाहिए +@msg `help` must be 1-{MAX_HELP} printable characters `help` में 1-{MAX_HELP} प्रिंट करने योग्य वर्ण होने चाहिए +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` 1-{MAX_EFFECT_ID} बाइट का इफ़ेक्ट id होना चाहिए +@msg `id` must be the id of an input object `id` किसी इनपुट ऑब्जेक्ट का id होना चाहिए +@msg `join` must be "miter", "round" or "bevel" `join` "miter", "round" या "bevel" होना चाहिए +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` और `group` में 1-{MAX_LABEL} प्रिंट करने योग्य वर्ण होने चाहिए +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` 1-{MAX_TEXT} होना चाहिए, और डिफ़ॉल्ट उससे लंबा नहीं होना चाहिए +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` और `maxItems` 0-{MAX_LIST} होने चाहिए, `maxItems` कम से कम 1, और डिफ़ॉल्ट में उतने ही आइटम होने चाहिए +@msg `name` must be a string `name` एक स्ट्रिंग होना चाहिए +@msg `opacity` must be a number `opacity` एक संख्या होना चाहिए +@msg `type` must be "path" or "compound" `type` "path" या "compound" होना चाहिए +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` में 1-{MAX_UNIT} प्रिंट करने योग्य वर्ण होने चाहिए +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` में `{other}` के लिए 1 से {MAX_CONDITION_VALUES} मान होने चाहिए +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` में `{other}` दिया गया है, जो इससे पहले घोषित पैरामीटर नहीं है +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` में अधिकतम {MAX_CONDITIONS} पैरामीटर हो सकते हैं +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` `{other}` के मान लेता है, जो चेकबॉक्स या विकल्प होना चाहिए +@msg `visible` must be true or false `visible` true या false होना चाहिए +@msg `{key}` must be a number from {_1} to {_2} `{key}` {_1} से {_2} तक की संख्या होना चाहिए +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` अधिकतम {MAX_ITEMS} प्रविष्टियों का ऐरे होना चाहिए +@msg a color default must be a color object or a hex string such as "#3a7bd5" रंग का डिफ़ॉल्ट एक रंग ऑब्जेक्ट या "#3a7bd5" जैसी हेक्स स्ट्रिंग होना चाहिए +@msg a color object or a hex string such as "#3a7bd5" एक रंग ऑब्जेक्ट या "#3a7bd5" जैसी हेक्स स्ट्रिंग +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines फ्रीफॉर्म ग्रेडिएंट में अधिकतम {MAX_ITEMS} पॉइंट और लाइनें होती हैं +@msg a freeform gradient line names a point it doesn't have फ्रीफॉर्म ग्रेडिएंट की एक लाइन ऐसे पॉइंट का नाम देती है जो ग्रेडिएंट में नहीं है +@msg a gradient needs 1-{MAX_ITEMS} stops ग्रेडिएंट को 1-{MAX_ITEMS} स्टॉप चाहिए +@msg a list of numbers संख्याओं की एक सूची +@msg a list of {min_items} to {max_items} numbers {min_items} से {max_items} संख्याओं की एक सूची +@msg a new object needs a `path` नए ऑब्जेक्ट को `path` चाहिए +@msg a number एक संख्या +@msg a painted live effect returns only `objects` पेंट करने वाला लाइव इफ़ेक्ट केवल `objects` लौटाता है +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` पेंट करने वाले लाइव इफ़ेक्ट के हिस्से में केवल `path`, `fillRule`, `fills` और `strokes` होते हैं +@msg a read-only object was left out एक केवल-पढ़ने योग्य ऑब्जेक्ट छोड़ दिया गया +@msg a read-only object was left out: return it, as {"id"} alone or as it was given एक केवल-पढ़ने योग्य ऑब्जेक्ट छोड़ दिया गया: इसे केवल {"id"} के रूप में या जैसा दिया गया था वैसा लौटाएँ +@msg a string एक स्ट्रिंग +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` स्ट्रोक में केवल `paint`, `width`, `join`, `cap` और `miterLimit` होते हैं +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} स्ट्रोक {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} होना चाहिए +@msg an effect must be {"id", "params"?, "visible"?} इफ़ेक्ट {"id", "params"?, "visible"?} होना चाहिए +@msg an effect on a fill returns only `fills` फ़िल पर इफ़ेक्ट केवल `fills` लौटाता है +@msg an effect on a stroke returns only `strokes` स्ट्रोक पर इफ़ेक्ट केवल `strokes` लौटाता है +@msg an object must be a JSON object ऑब्जेक्ट एक JSON ऑब्जेक्ट होना चाहिए +@msg bad range अमान्य सीमा +@msg choices must be 1-64 non-empty, printable strings विकल्प 1-64 खाली नहीं, प्रिंट करने योग्य स्ट्रिंग होने चाहिए +@msg coordinate out of range (limit ±{MAX_COORD} pt) निर्देशांक सीमा से बाहर है (सीमा ±{MAX_COORD} pt) +@msg default is not one of the options डिफ़ॉल्ट विकल्पों में से कोई नहीं है +@msg each piece needs a `path` हर हिस्से को `path` चाहिए +@msg is listed twice दो बार सूचीबद्ध है +@msg pattern transform out of range पैटर्न ट्रांसफ़ॉर्म सीमा से बाहर है +@msg read-only field '{field}' changed केवल-पढ़ने योग्य फ़ील्ड '{field}' बदल गया +@msg stops must be in order of `offset` स्टॉप `offset` के क्रम में होने चाहिए +@msg the document has no pattern {pattern} डॉक्यूमेंट में पैटर्न {pattern} नहीं है +@msg the document has no swatch {n} डॉक्यूमेंट में स्वॉच {n} नहीं है +@msg the output आउटपुट +@msg the path has no anchor points पाथ में कोई एंकर पॉइंट नहीं है +@msg too many anchor points (limit {MAX_ANCHORS}) बहुत अधिक एंकर पॉइंट (सीमा {MAX_ANCHORS}) +@msg true or false true या false +@msg unknown key `{k}`; the keys here are {_1} अज्ञात कुंजी `{k}`; यहाँ की कुंजियाँ {_1} हैं +@msg {_1} is listed twice {_1} दो बार सूचीबद्ध है +@msg {_1} is not the id of an input object {_1} किसी इनपुट ऑब्जेक्ट का id नहीं है +@msg {_1} is read-only {_1} केवल-पढ़ने योग्य है +@msg {what} must be from {lo} to {hi} {what} {lo} से {hi} तक होना चाहिए +@msg a color component एक रंग घटक +@msg a plate एक प्लेट # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? "{name}" स्थापित करें, जो ये अनुमतियां मांगता है? ID: {id} ID: {id} @@ -5505,7 +5572,7 @@ antiAlias Sharp शार्प @msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} समाप्त हो गया, लेकिन डॉक्यूमेंट या प्लग-इन इस बीच बदल गया: इसे फिर से चलाएं @msg {_1} font families available {_1} फ़ॉन्ट परिवारों उपलब्ध @msg {_1} has no {_2} style {_1} में कोई {_2} स्टाइल नहीं है -@msg {_1} is {_2} bytes (limit {_3}) {_1} {_2} बाइट्स (limit {_3}) है। +@msg {_1} is {_2} bytes (limit {_3}) {_1} {_2} बाइट्स का है (सीमा {_3}) @msg {_1} is a live effect (Effect › Plug-ins) {_1} एक लाइव इफ़ेक्ट (इफ़ेक्टी> प्लग-इन) है। @msg {_1} is already used by {_2} {_1} का उपयोग पहले से ही {_2} द्वारा किया जाता है @msg {_1} is an exporter (File › Export › Export As…) {_1} एक निर्यातक (फ़ाइल> निर्यात › निर्यात As...) है। @@ -5514,7 +5581,7 @@ antiAlias Sharp शार्प @msg {_1} isn't a path {_1} एक पाथ नहीं है @msg {_1} MB of document, at most {_2} MB {_1} MB of document, at most {_2} MB @msg {_1} MB of images, at most {_2} MB {_1} MB of images, at most {_2} MB -@msg {_1} objects (limit {MAX_OBJECTS}) {_1} ऑब्जेक्ट्स (limit {MAX_OBJECTS}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} ऑब्जेक्ट्स (सीमा {MAX_OBJECTS}) @msg {_1} options: {e} {_1} ऑप्शन्स: {e} @msg {_1} paints nothing to rasterize {_1} कुछ भी नहीं पेंट करता है @msg {_1} passwords can have up to {MAX_LEGACY_PASSWORD} ASCII characters (PDF 1.7 and later take any) {_1} पासवर्ड के पास {MAX_LEGACY_PASSWORD} ASCII वर्ण (PDF 1.7) और बाद में कोई लेना) हो सकता है। @@ -5618,7 +5685,7 @@ antiAlias Sharp शार्प @msg {v} is not an asset id {v} एक परिसंपत्ति आईडी नहीं है @msg {what} can't be intertwined {what} अंतर नहीं किया जा सकता है @msg {what} doesn't fit the paper with its bleed and marks {what} अपने ब्लेड और मार्क्स के साथ पेपर को फिट नहीं करता है -@msg {what} points outside memory {what} स्मृति के बाहर अंक +@msg {what} points outside memory {what} मेमोरी के बाहर इंगित करता है @msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} {MAX_TILES} टाइल्स से अधिक प्रिंट करेगा: इसे नीचे स्केल करें @msg `{_1}` can't be edited or removed `{_1}` को संपादित या हटाया नहीं जा सकता है @msg `{_1}` has no brushes to apply `{_1}` को लागू करने के लिए कोई ब्रश नहीं है @@ -5648,13 +5715,13 @@ antiAlias Sharp शार्प @msg `{k}` is true or false `{k}` true या false है @msg `{k}` isn't an export setting: {_1} `{k}` एक निर्यात सेटिंग नहीं है: {_1} @msg `{k}` must be [x, y] `{k}` होना चाहिए [x, y] -@msg `{k}` must be {what} `{k}` होना चाहिए {what} +@msg `{k}` must be {what} `{k}` {what} होना चाहिए @msg `{k}` must be {what}, not {v} `{k}` होना चाहिए {what}, नहीं {v} @msg `{k}` must be a byte offset `{k}` को एक बाइट ऑफ़सेट होना चाहिए -@msg `{k}` must be a number `{k}` होना चाहिए +@msg `{k}` must be a number `{k}` एक संख्या होना चाहिए @msg `{k}` must be a number of points `{k}` को कई अंक होना चाहिए @msg `{k}` must be a string `{k}` एक स्ट्रिंग होना चाहिए -@msg `{k}` must be a whole number `{k}` होना चाहिए +@msg `{k}` must be a whole number `{k}` एक पूर्ण संख्या होना चाहिए @msg `{k}` must be from {lo} to {hi} for {key} `{k}` होना चाहिए {lo} से {hi} {key} के लिए @msg `{k}` must be one of {_1} `{k}` को {_1} में से एक होना चाहिए @msg `{keep}` is not a side of this axis `{keep}` इस अक्ष का एक पक्ष नहीं है @@ -5672,13 +5739,13 @@ antiAlias Sharp शार्प @msg `{key}` must be a #rrggbb color `{key}` एक #rrggbb रंग होना चाहिए @msg `{key}` must be a #rrggbb colour `{key}` एक #rrggbb रंग होना चाहिए @msg `{key}` must be a non-negative integer `{key}` को एक गैर-नकारात्मक पूर्णांक होना चाहिए -@msg `{key}` must be a number `{key}` होना चाहिए -@msg `{key}` must be a number {_1}..{_2} `{key}` होना चाहिए a number {_1}.{_2} -@msg `{key}` must be a number not below 0 `{key}` को 0 से नीचे नहीं होना चाहिए -@msg `{key}` must be a number, not {v} `{key}` एक नंबर होना चाहिए, नहीं {v} +@msg `{key}` must be a number `{key}` एक संख्या होना चाहिए +@msg `{key}` must be a number {_1}..{_2} `{key}` {_1}..{_2} की एक संख्या होना चाहिए +@msg `{key}` must be a number not below 0 `{key}` 0 से कम नहीं होने वाली संख्या होना चाहिए +@msg `{key}` must be a number, not {v} `{key}` एक संख्या होना चाहिए, {v} नहीं @msg `{key}` must be a profile name or null `{key}` को प्रोफ़ाइल नाम या null होना चाहिए @msg `{key}` must be a string `{key}` एक स्ट्रिंग होना चाहिए -@msg `{key}` must be a whole number `{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 a whole number of at least 0, not {n} `{key}` को कम से कम 0 की पूरी संख्या होना चाहिए, नहीं {n} @msg `{key}` must be an appearance item index (paint order) or null `{key}` एक उपस्थिति आइटम सूचकांक (पेंट ऑर्डर) या null होना चाहिए @@ -5886,7 +5953,7 @@ antiAlias Sharp शार्प @msg `mode` must be one of {_1} `mode` को {_1} में से एक होना चाहिए @msg `mojikumi` must be "none" or "lineEndHalf" `mojikumi` होना चाहिए "none" or "lineEndHalf" @msg `name` is empty `name` खाली है -@msg `name` must be 1-64 printable characters `name` को 1-64 प्रिंट करने योग्य वर्ण होना चाहिए +@msg `name` must be 1-64 printable characters `name` में 1-64 प्रिंट करने योग्य वर्ण होने चाहिए @msg `names` is a list of preset names `names` प्रीसेट नामों की सूची है @msg `names` lists preset names `names` प्रीसेट नामों की सूची देता है @msg `names` must be a list of names `names` को नामों की सूची होना चाहिए @@ -6093,7 +6160,7 @@ antiAlias Sharp शार्प @msg an artboard is listed twice एक आर्टबोर्ड को दो बार सूचीबद्ध किया गया है @msg an empty artboard has no width or height to scale to एक खाली आर्टबोर्ड में स्केल करने के लिए कोई चौड़ाई या ऊंचाई नहीं है @msg an exporter always gets an export's budget: leave `budget` out एक निर्यातक हमेशा एक निर्यात बजट हो जाता है: `budget` बाहर छोड़ दें -@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` एक निर्यातक ने 1 को {MAX_EXTENSIONS} `extensions` को घोषित किया +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` एक्सपोर्टर 1 से {MAX_EXTENSIONS} `extensions` घोषित करता है @msg an image has no pixels and was left out एक इमेज में कोई पिक्सेल नहीं है और इसे छोड़ दिया गया था @msg an image is too large for DXF and was left out DXF के लिए एक इमेज बहुत बड़ी है और इसे छोड़ दिया गया था @msg an image of {_1} MB, at most {_2} MB {_1} MB की एक इमेज, अधिकांश {_2} MB पर @@ -6147,7 +6214,7 @@ antiAlias Sharp शार्प @msg at most {MAX_MATCHES} matches are kept अधिकांश {MAX_MATCHES} मैचों को रखा जाता है @msg at most {MAX_POINTS} points {MAX_POINTS} पॉइंट पर @msg at most {MAX_QUEUE} files सबसे अधिक {MAX_QUEUE} फ़ाइलों में -@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` अधिकांश {MAX_UNGROUPED} मापदंडों पर समूहों के बाहर: दूसरों को `group` देना +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` समूहों के बाहर अधिकतम {MAX_UNGROUPED} पैरामीटर: बाकी को एक `group` दें @msg at must be [x, y] at: [x, y] होना चाहिए @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 at/rect lie outside the canvas कैनवास के बाहर / सही झूठ @@ -6224,7 +6291,7 @@ antiAlias Sharp शार्प @msg can't add the editing data for other apps: {why} अन्य ऐप्स के लिए संपादन डेटा नहीं जोड़ सकते: {why} @msg can't edit the written PDF: {why} लिखित PDF संपादित नहीं कर सकता: {why} @msg can't encode the document: {0} डॉक्यूमेंट को एनकोड नहीं कर सकता: {0} -@msg can't encode the input इनपुट को एनकोड नहीं कर सकता +@msg can't encode the input इनपुट एनकोड नहीं किया जा सकता @msg can't encrypt the PDF: {why} PDF को एन्क्रिप्ट नहीं कर सकता: {why} @msg can't linearise the PDF: {why} PDF को रैखिक नहीं कर सकता: {why} @msg can't open `{_1}`: {_2} `{_1}` नहीं खोल सकते: {_2} @@ -6455,8 +6522,8 @@ antiAlias Sharp शार्प @msg Expand: no gradient fills विस्तार: कोई ग्रेडिएंट भरता नहीं @msg Expand: nothing to expand विस्तार: विस्तार करने के लिए कुछ नहीं @msg expanded SVG styles exceed the import size limit विस्तारित SVG स्टाइल्स आयात आकार सीमा से अधिक है -@msg expected {"dataBase64"} उम्मीद {"dataBase64"} -@msg expected {"objects"} उम्मीद {"objects"} +@msg expected {"dataBase64"} अपेक्षित: {"dataBase64"} +@msg expected {"objects"} अपेक्षित: {"objects"} @msg export `{name}`: {e} निर्यात `{name}`: {e} @msg Export cancelled निर्यात रद्द @msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft स्क्रीन के लिए एक्सपोर्ट png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai या vectorcraft लिखता है @@ -6651,7 +6718,7 @@ antiAlias Sharp शार्प @msg Invalid dimension font name अमान्य आयाम फ़ॉन्ट नाम @msg Invalid dimension offset अमान्य आयाम ऑफसेट @msg Invalid drawing scale अमान्य ड्राइंग स्केल -@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - Invalid id {_1}: A-Z a-z 0-9 से 1-64 अक्षरों का उपयोग करें। +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - अमान्य id {_1}: A-Z a-z 0-9 . _ - में से 1-64 वर्णों का उपयोग करें @msg invalid image data for `{0}` `{0}` के लिए अमान्य इमेज डेटा @msg invalid native panel set अमान्य मूल पैनल सेट @msg invalid object id {value}: expected a non-negative integer अमान्य ऑब्जेक्ट id {value}: एक गैर-नकारात्मक पूर्णांक की उम्मीद @@ -6753,9 +6820,9 @@ antiAlias Sharp शार्प @msg Loading VectorCraft… वेक्टर क्रेफ्ट लोड हो रहा है ... @msg Lock Workspace is not available in VectorCraft लॉक वर्कस्पेस VectorCraft में उपलब्ध नहीं है @msg Make Guides: select paths गाइड बनाना: पाथ सिलेक्ट करें -@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) प्रकटन {_1} बाइट्स (limit {MAX_MANIFEST_BYTES}) -@msg manifest is not UTF-8 manifest: प्रकट नहीं है UTF-8 -@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) प्रकटन बहुत लंबा है (सीमा {MAX_MANIFEST_BYTES} बाइट्स) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) मैनिफ़ेस्ट {_1} बाइट्स का है (सीमा {MAX_MANIFEST_BYTES}) +@msg manifest is not UTF-8 मैनिफ़ेस्ट UTF-8 में नहीं है +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) मैनिफ़ेस्ट बहुत लंबा है (सीमा {MAX_MANIFEST_BYTES} बाइट्स) @msg manifest: {e} प्रकटन: {e} @msg margin: a {m} pt margin leaves no imageable area on the paper मार्जिन: एक {m} pt मार्जिन कागज पर कोई इमेजेबल क्षेत्र नहीं छोड़ता है @msg mask art ends outside a mask dictionary मास्क कला एक मास्क शब्दकोश के बाहर समाप्त होता है @@ -6852,7 +6919,7 @@ antiAlias Sharp शार्प @msg mode is set, add or toggle, not `{other}` mode set, add या toggle है, `{other}` नहीं @msg mode must be "cmyk" or "rgb" mode: मोड "cmyk" या "rgb" होना चाहिए @msg mode must be area|onPath, got {o} mode: मोड होना चाहिए क्षेत्रफल (onPath), {o} मिला -@msg module is {_1} bytes (limit {_2}) मॉड्यूल {_1} बाइट्स (सीम {_2}) है। +@msg module is {_1} bytes (limit {_2}) मॉड्यूल {_1} बाइट्स का है (सीमा {_2}) @msg more than {MAX_BATCH_FILES} data sets {MAX_BATCH_FILES} डेटा सेट से अधिक @msg more than {MAX_BATCH_FILES} files {MAX_BATCH_FILES} फ़ाइलों से अधिक @msg more than {MAX_CSV_COLS} variables: Import Data Sets reads at most {MAX_CSV_COLS} {MAX_CSV_COLS} से अधिक चर: आयात डेटा सेट अधिकांश {MAX_CSV_COLS} पर पढ़ा जाता है। @@ -7148,7 +7215,7 @@ antiAlias Sharp शार्प @msg not a valid SVGZ file: {e} एक मान्य SVGZ फ़ाइल नहीं: {e} @msg not a VectorCraft file: {0} एक वेक्टर क्रेफ्ट फ़ाइल नहीं: {0} @msg not a VectorCraft shortcut set (missing `overrides`) एक VectorCraft शॉर्टकट सेट नहीं है ( `overrides`) -@msg not a WebAssembly binary (missing \0asm header) नहीं एक WebAssembly द्विआधारी (missing \0asm header) +@msg not a WebAssembly binary (missing \0asm header) WebAssembly बाइनरी नहीं है (\0asm हेडर नहीं है) @msg not a Word document: its compound file has no WordDocument stream एक वर्ड डॉक्यूमेंट नहीं: इसकी यौगिक फ़ाइल में WordDocument स्ट्रीम नहीं है @msg not a Word document: its main part is not a WordprocessingML document वर्ड डॉक्यूमेंट नहीं: इसका मुख्य हिस्सा वर्डप्रोसेसिंगएमएल डॉक्यूमेंट नहीं है @msg not a Word document: its package has no main document part नहीं एक वर्ड डॉक्यूमेंट: इसके पैकेज में कोई मुख्य डॉक्यूमेंट हिस्सा नहीं है @@ -7168,7 +7235,7 @@ antiAlias Sharp शार्प @msg not editing an opacity mask एक opacity मास्क संपादित न करें @msg not found नहीं मिला @msg not in pattern editing mode पैटर्न संपादन मोड में नहीं -@msg not JSON: {e} नहीं JSON: {e} +@msg not JSON: {e} JSON नहीं है: {e} @msg not type on a path outside perspective दृष्टिकोण के बाहर एक पाथ पर टाइप न करें @msg nothing encoded कुछ भी नहीं @msg nothing in data set {name} was applied: no object is bound to a variable it has a value for डेटा सेट {name} में कुछ भी लागू नहीं किया गया था: कोई ऑब्जेक्ट एक चर के लिए बाध्य नहीं है, इसके लिए एक मूल्य है। @@ -7254,7 +7321,7 @@ antiAlias Sharp शार्प @msg Outline Stroke: no stroked paths selected आउटलाइन स्ट्रोक: सिलेक्टेड कोई स्ट्रोक पाथ नहीं @msg outlineText {v}: true or false outlineText {v}: true या false @msg output intent profile: {e} उत्पादन आशय प्रोफ़ाइल: {e} -@msg output size ({what} is {len} bytes, limit {max}) उत्पादन का आकार ({what} {len} बाइट्स है, {max} को सीमित करें) +@msg output size ({what} is {len} bytes, limit {max}) आउटपुट का आकार ({what} {len} बाइट्स का है, सीमा {max}) @msg output.destination: {_1} files are CMYK, and “{name}” is an RGB profile output.destination: {_1} फ़ाइलें CMYK हैं, और "{name}" एक RGB प्रोफ़ाइल है @msg output.destination: {e} output.destination: {e} @msg output.destination: no RGB or CMYK profile is called “{name}” output.destination: "{name}" नाम की कोई RGB या CMYK प्रोफ़ाइल नहीं है @@ -7325,13 +7392,13 @@ antiAlias Sharp शार्प @msg plane must be left|right|ground plane को left|right|ground होना चाहिए @msg plane must be left|right|ground|none plane को left|right|ground|none होना चाहिए @msg plug-in ABI: {0} प्लग-इन ABI: {0} -@msg plug-in exceeded its {0} प्लग-इन अपने {0} से अधिक है +@msg plug-in exceeded its {0} प्लग-इन ने अपना {0} पार कर लिया @msg plug-in failed: {0} प्लग-इन विफल: {0} -@msg plug-in manifest: {0} प्लग-इन प्रकटन: {0} +@msg plug-in manifest: {0} प्लग-इन मैनिफ़ेस्ट: {0} @msg plug-in output: {0} प्लग-इन आउटपुट: {0} @msg plug-in parameters must be a JSON object प्लग-इन पैरामीटर एक JSON ऑब्जेक्ट होना चाहिए @msg plug-in parameters: {0} प्लग-इन पैरामीटर: {0} -@msg plug-in trapped: {0} प्लग-इन फँसा: {0} +@msg plug-in trapped: {0} प्लग-इन में ट्रैप हुआ: {0} @msg Plug-ins can't be loaded from {path}: {e} प्लग-इन {path} से लोड नहीं किया जा सकता: {e} @msg point is outside the mesh बिंदु मेश के बाहर है @msg points must be [t, left, right] with non-negative widths points: गैर-नकारात्मक चौड़ाई के साथ अंक [t, left, right] होना चाहिए @@ -7753,7 +7820,7 @@ antiAlias Sharp शार्प @msg the object isn't on a perspective plane ऑब्जेक्ट एक पर्सपेक्टिव विमान पर नहीं है @msg the object would cross the horizon of the perspective plane ऑब्जेक्ट पर्सपेक्टिव विमान के क्षितिज को पार करेगी @msg the objects don't match the plan ऑब्जेक्ट्स प्लान से मेल नहीं खाते हैं -@msg the objects to change are no longer in the document ऑब्जेक्ट्स को बदलने के लिए अब डॉक्यूमेंट में नहीं हैं +@msg the objects to change are no longer in the document बदले जाने वाले ऑब्जेक्ट्स अब डॉक्यूमेंट में नहीं हैं @msg the objects would leave the grid ऑब्जेक्ट्स ग्रिड को छोड़ देंगे @msg the open and permissions passwords must differ खुला और अनुमति पासवर्ड अलग होना चाहिए @msg the open dialog has no Reset ओपन संवाद में कोई रीसेट नहीं है @@ -7813,7 +7880,7 @@ antiAlias Sharp शार्प @msg the replacement font is not available प्रतिस्थापन फ़ॉन्ट उपलब्ध नहीं है @msg the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs) RTF फ़ाइल में कई पैराग्राफ या प्रारूपण परिवर्तन होते हैं जो टाइप (अधिकांश {MAX_PARAGRAPHS} पैराग्राफ और {MAX_RUNS} रन पर) होते हैं। @msg the RTF file is too large to place as type (at most {_1} MB) RTF फ़ाइल बहुत बड़े प्रकार के रूप में जगह है (अधिकांश {_1} MB पर) -@msg the runtime panicked रनटाइम panicked +@msg the runtime panicked रनटाइम क्रैश हो गया @msg the Save PDF dialog didn't open सेव करें PDF संवाद खुला नहीं था @msg the scale is not a usable number स्केल एक प्रयोग योग्य संख्या नहीं है @msg the screen color sampler is already open स्क्रीन रंग नमूना पहले से ही खुला है @@ -7952,7 +8019,7 @@ antiAlias Sharp शार्प @msg type that changes writing direction inside a text object opens in the direction of its first character टेक्स्ट ऑब्जेक्ट के भीतर लेखन दिशा बदलने वाला टेक्स्ट अपने पहले वर्ण की दिशा में खुलता है @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip प्रकार जुड़े क्षेत्रों में एक मास्क के रूप में इस्तेमाल किया, रैपराउंड टेक्स्ट या एक मिश्रित पाथ क्लिप नहीं करता है @msg Unembedded to {_1} Unembed to {_1} -@msg unexpected host trap अप्रत्याशित मेजबान जाल +@msg unexpected host trap होस्ट में अप्रत्याशित ट्रैप @msg unexpected resources अप्रत्याशित संसाधन @msg unknown {k} key `{key}` (size, position) अज्ञात {k} कुंजी `{key}` (आकार, स्थिति) @msg unknown {key} `{v}` (none, short or full) अज्ञात {key} `{v}` (one, short or full) @@ -8093,9 +8160,9 @@ antiAlias Sharp शार्प @msg variables are locked चर लॉक कर रहे हैं @msg variables that aren't text were left out: {kinds} वेरिएबल जो टेक्स्ट नहीं छोड़े गए थे: {kinds} @msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version ने {v} लौटाया; यह होस्ट संस्करण {ABI_VERSION} लागू करता है -@msg vc_alloc returned 0 (out of memory) vc_alloc 0 लौटे (मेमोरी से बाहर) -@msg vc_alloc returned a block outside memory vc_alloc स्मृति के बाहर एक ब्लॉक लौटा -@msg vc_run returned error code {packed} vc_run त्रुटि कोड लौटा {packed} +@msg vc_alloc returned 0 (out of memory) vc_alloc ने 0 लौटाया (मेमोरी खत्म) +@msg vc_alloc returned a block outside memory vc_alloc ने मेमोरी के बाहर का ब्लॉक लौटाया +@msg vc_run returned error code {packed} vc_run ने त्रुटि कोड {packed} लौटाया @msg VectorCraft doesn't search other apps' or the system's folders: {path} वेक्टर क्रेफ्ट अन्य एप्लिकेशन या सिस्टम के फ़ोल्डर्स की खोज नहीं करता है: {path} @msg VectorCraft doesn't search the Shared or Public folder in Users: {path} वेक्टर क्रेफ्ट उपयोगकर्ताओं में साझा या सार्वजनिक फ़ोल्डर की खोज नहीं करता है: {path} @msg VectorCraft failed to start: {e} वेक्टर क्रेफ्ट शुरू करने में विफल रहा: {e} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c3ed22bce8..a485a10283 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6077,6 +6077,110 @@ axis Y Axis Y tengely @msg a live effect runs while the document is drawn, so it can't be `heavy` az élő effektus a dokumentum rajzolása közben fut, ezért nem lehet `heavy` @msg an exporter always gets an export's budget: leave `budget` out az exportáló bővítmény mindig az exportálás keretét kapja: hagyd ki a `budget` kulcsot @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants a(z) „{_1}” nagy számítási keretet kér (futásonként legfeljebb {minutes} perc és a szokásos utasításszám {times}-szorosa): egy személynek jóvá kell hagynia az asztali alkalmazásban, vagy add meg az `allowHeavy: true` értéket egy --allow-plugin-grants kapcsolóval indított munkamenetben +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters a `name` 1-64 nyomtatható karakter legyen +@msg `version`, `description` or `author` is too long a `version`, a `description` vagy az `author` túl hosszú +@msg `warnings` must be a list of strings a `warnings` karakterláncok listája legyen +@msg `{k}` must be one of {_1} `{k}`: ezek egyike kell: {_1} +@msg `{k}` must be {what} `{k}`: {what} kell +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` az exportáló bővítmény 1–{MAX_EXTENSIONS} `extensions` értéket ad meg +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` csoporton kívül legfeljebb {MAX_UNGROUPED} paraméter lehet: a többinek adj meg `group` értéket +@msg can't encode the input a bemenet nem kódolható +@msg expected {"dataBase64"} várt formátum: {"dataBase64"} +@msg expected {"objects"} várt formátum: {"objects"} +@msg export `{name}`: {e} `{name}` modulexport: {e} +@msg extension {e} is listed twice a(z) {e} kiterjesztés kétszer szerepel +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot {e} kiterjesztés: 1-16 kisbetűt és számjegyet használj, pont nélkül +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - érvénytelen azonosító: {_1}; 1-64 karaktert használj ezekből: A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} érvénytelen paraméternév: {k} +@msg manifest is not UTF-8 a leíró nem UTF-8 kódolású +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) a leíró túl hosszú (korlát: {MAX_MANIFEST_BYTES} bájt) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) a leíró mérete {_1} bájt (korlát: {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) a modul mérete {_1} bájt (korlát: {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own jóváhagyás szükséges: ezt kéri: {0}, amit a felhasználó a külön telepítéskor ad meg +@msg not JSON: {e} nem JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) nem WebAssembly bináris (hiányzik a \0asm fejléc) +@msg parameter `{k}`: {what} `{k}` paraméter: {what} +@msg parameter `{name}`: {what} `{name}` paraméter: {what} +@msg plug-in parameters must be a JSON object a bővítmény paraméterei JSON-objektumot alkossanak +@msg the module must export its linear memory as `memory` a modulnak `memory` néven kell exportálnia a lineáris memóriáját +@msg the objects to change are no longer in the document a módosítandó objektumok már nincsenek a dokumentumban +@msg the runtime panicked a futtatókörnyezet összeomlott +@msg unexpected host trap váratlan megszakítás (trap) a gazdaprogramban +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} a vc_abi_version {v} értéket adott vissza; ez a gazdaprogram a(z) {ABI_VERSION}. verziót valósítja meg +@msg vc_alloc returned 0 (out of memory) a vc_alloc 0-t adott vissza (elfogyott a memória) +@msg vc_alloc returned a block outside memory a vc_alloc a memórián kívüli blokkot adott vissza +@msg vc_run returned error code {packed} a vc_run {packed} hibakódot adott vissza +@msg {_1} is not a file nem fájl: {_1} +@msg {_1} is {_2} bytes (limit {_3}) {_1}: {_2} bájt (korlát: {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objektum (korlát: {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what}: a memórián kívülre mutat +@msg `angle` must be a number `angle`: szám kell +@msg `aspect` must be from 0.01 to 100 `aspect`: 0.01 és 100 közötti érték kell +@msg `cap` must be "butt", "round" or "square" `cap`: "butt", "round" vagy "square" kell +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule`: "nonzero" vagy "evenodd" kell +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl`: legfeljebb {MAX_HELP_URL} bájtos https:// cím kell +@msg `help` must be 1-{MAX_HELP} printable characters a `help` 1-{MAX_HELP} nyomtatható karakter legyen +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id`: 1-{MAX_EFFECT_ID} bájtos effektusazonosító kell +@msg `id` must be the id of an input object `id`: egy bemeneti objektum azonosítója kell +@msg `join` must be "miter", "round" or "bevel" `join`: "miter", "round" vagy "bevel" kell +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters a `label` és a `group` 1-{MAX_LABEL} nyomtatható karakter legyen +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer a `maxLength` 1-{MAX_TEXT} legyen, és az alapértelmezett érték ne legyen hosszabb +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many a `minItems` és a `maxItems` 0-{MAX_LIST} legyen, a `maxItems` legalább 1, és az alapértelmezett érték ennyi elemből álljon +@msg `name` must be a string `name`: karakterlánc kell +@msg `opacity` must be a number `opacity`: szám kell +@msg `type` must be "path" or "compound" `type`: "path" vagy "compound" kell +@msg `unit` must be 1-{MAX_UNIT} printable characters a `unit` 1-{MAX_UNIT} nyomtatható karakter legyen +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` a `visibleWhen` 1-{MAX_CONDITION_VALUES} értéket adjon meg ehhez: `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one a `visibleWhen` ezt nevezi meg: `{other}`, de ez nem az ennél korábban megadott paraméterek egyike +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters a `visibleWhen` legfeljebb {MAX_CONDITIONS} paramétert nevezhet meg +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice a `visibleWhen` a(z) `{other}` értékeit veszi át, egy jelölőnégyzetét vagy egy választásét +@msg `visible` must be true or false `visible`: true vagy false kell +@msg `{key}` must be a number from {_1} to {_2} `{key}`: {_1} és {_2} közötti szám kell +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}`: legfeljebb {MAX_ITEMS} elemű tömb kell +@msg a color default must be a color object or a hex string such as "#3a7bd5" a szín alapértelmezett értéke színobjektum vagy hexadecimális karakterlánc legyen, például "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" színobjektum vagy hexadecimális karakterlánc, például "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines a szabad formájú színátmenet legfeljebb {MAX_ITEMS} pontot és vonalat tartalmaz +@msg a freeform gradient line names a point it doesn't have a szabad formájú színátmenet egyik vonala olyan pontot nevez meg, amely nincs benne +@msg a gradient needs 1-{MAX_ITEMS} stops a színátmenethez 1-{MAX_ITEMS} színátmenet-pont kell +@msg a list of numbers számok listája +@msg a list of {min_items} to {max_items} numbers {min_items}-{max_items} számból álló lista +@msg a new object needs a `path` az új objektumhoz `path` kell +@msg a number szám +@msg a painted live effect returns only `objects` a festő élő effektus csak `objects` értéket ad vissza +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` a festő élő effektus darabjának csak `path`, `fillRule`, `fills` és `strokes` kulcsa van +@msg a read-only object was left out kimaradt egy írásvédett objektum +@msg a read-only object was left out: return it, as {"id"} alone or as it was given kimaradt egy írásvédett objektum: adja vissza önmagában {"id"} formában vagy úgy, ahogy megkapta +@msg a string karakterlánc +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` a körvonalnak csak `paint`, `width`, `join`, `cap` és `miterLimit` kulcsa van +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} a körvonal formája {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} legyen +@msg an effect must be {"id", "params"?, "visible"?} az effektus formája {"id", "params"?, "visible"?} legyen +@msg an effect on a fill returns only `fills` a kitöltésen lévő effektus csak `fills` értéket ad vissza +@msg an effect on a stroke returns only `strokes` a körvonalon lévő effektus csak `strokes` értéket ad vissza +@msg an object must be a JSON object az objektum JSON-objektum legyen +@msg bad range érvénytelen tartomány +@msg choices must be 1-64 non-empty, printable strings a választási lehetőségek 1-64 nem üres, nyomtatható karakterlánc legyenek +@msg coordinate out of range (limit ±{MAX_COORD} pt) tartományon kívüli koordináta (korlát: ±{MAX_COORD} pt) +@msg default is not one of the options az alapértelmezett érték nem a lehetőségek egyike +@msg each piece needs a `path` minden darabhoz `path` kell +@msg is listed twice kétszer szerepel +@msg pattern transform out of range tartományon kívüli mintázat-transzformáció +@msg read-only field '{field}' changed megváltozott a(z) '{field}' írásvédett mező +@msg stops must be in order of `offset` a színátmenet-pontok `offset` szerint rendezve legyenek +@msg the document has no pattern {pattern} a dokumentumban nincs {pattern} mintázat +@msg the document has no swatch {n} a dokumentumban nincs {n} színminta +@msg the output a kimenet +@msg the path has no anchor points az útvonalnak nincsenek horgonypontjai +@msg too many anchor points (limit {MAX_ANCHORS}) túl sok horgonypont (korlát: {MAX_ANCHORS}) +@msg true or false true vagy false +@msg unknown key `{k}`; the keys here are {_1} ismeretlen kulcs: `{k}`; az itt használható kulcsok: {_1} +@msg {_1} is listed twice {_1} kétszer szerepel +@msg {_1} is not the id of an input object {_1} nem egy bemeneti objektum azonosítója +@msg {_1} is read-only {_1} írásvédett +@msg {what} must be from {lo} to {hi} {what}: {lo} és {hi} közötti érték kell +@msg a color component színösszetevő +@msg a plate nyomólemez # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Telepíted a(z) „{name}” bővítményt, amely ezeket az engedélyeket kéri? ID: {id} Azonosító: {id} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a6edc31792..bbf73d0940 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5719,6 +5719,110 @@ axis Y Axis Asse Y @msg a live effect runs while the document is drawn, so it can't be `heavy` un effetto dinamico viene eseguito mentre il documento viene disegnato, quindi non può essere `heavy` @msg an exporter always gets an export's budget: leave `budget` out un plug-in di esportazione riceve sempre il budget di un’esportazione: ometti `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” chiede un budget di calcolo elevato (ogni esecuzione fino a {minutes} minuti e {times} volte le istruzioni consuete): una persona deve approvarlo nell’app desktop, oppure passa `allowHeavy: true` in una sessione avviata con --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` deve avere 1-64 caratteri stampabili +@msg `version`, `description` or `author` is too long `version`, `description` o `author` è troppo lungo +@msg `warnings` must be a list of strings `warnings` deve essere un elenco di stringhe +@msg `{k}` must be one of {_1} `{k}` deve essere uno dei seguenti: {_1} +@msg `{k}` must be {what} `{k}` deve essere {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un plug-in di esportazione dichiara da 1 a {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` al massimo {MAX_UNGROUPED} parametri fuori dai gruppi: assegna agli altri un `group` +@msg can't encode the input impossibile codificare l’input +@msg expected {"dataBase64"} previsto {"dataBase64"} +@msg expected {"objects"} previsto {"objects"} +@msg export `{name}`: {e} esportazione del modulo `{name}`: {e} +@msg extension {e} is listed twice l’estensione {e} è elencata due volte +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot estensione {e}: usa 1-16 lettere minuscole e cifre, senza il punto +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ID non valido {_1}: usa 1-64 caratteri tra A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} nome di parametro non valido {k} +@msg manifest is not UTF-8 il manifest non è in UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) il manifest è troppo lungo (limite {MAX_MANIFEST_BYTES} byte) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) il manifest occupa {_1} byte (limite {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) il modulo occupa {_1} byte (limite {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own richiede approvazione: chiede {0}, che l’utente concede installandolo da solo +@msg not JSON: {e} non è JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) non è un binario WebAssembly (manca l’intestazione \0asm) +@msg parameter `{k}`: {what} parametro `{k}`: {what} +@msg parameter `{name}`: {what} parametro `{name}`: {what} +@msg plug-in parameters must be a JSON object i parametri del plug-in devono essere un oggetto JSON +@msg the module must export its linear memory as `memory` il modulo deve esportare la sua memoria lineare come `memory` +@msg the objects to change are no longer in the document gli oggetti da modificare non sono più nel documento +@msg the runtime panicked l’ambiente di esecuzione si è bloccato +@msg unexpected host trap interruzione imprevista (trap) dell’host +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version ha restituito {v}; questo host implementa la versione {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc ha restituito 0 (memoria esaurita) +@msg vc_alloc returned a block outside memory vc_alloc ha restituito un blocco fuori dalla memoria +@msg vc_run returned error code {packed} vc_run ha restituito il codice di errore {packed} +@msg {_1} is not a file {_1} non è un file +@msg {_1} is {_2} bytes (limit {_3}) {_1} occupa {_2} byte (limite {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} oggetti (limite {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} punta fuori dalla memoria +@msg `angle` must be a number `angle` deve essere un numero +@msg `aspect` must be from 0.01 to 100 `aspect` deve essere compreso tra 0.01 e 100 +@msg `cap` must be "butt", "round" or "square" `cap` deve essere "butt", "round" o "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` deve essere "nonzero" o "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` deve essere un indirizzo https:// di al massimo {MAX_HELP_URL} byte +@msg `help` must be 1-{MAX_HELP} printable characters `help` deve avere 1-{MAX_HELP} caratteri stampabili +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` deve essere un id di effetto di 1-{MAX_EFFECT_ID} byte +@msg `id` must be the id of an input object `id` deve essere l’id di un oggetto di input +@msg `join` must be "miter", "round" or "bevel" `join` deve essere "miter", "round" o "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` e `group` devono avere 1-{MAX_LABEL} caratteri stampabili +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` deve essere 1-{MAX_TEXT} e il valore predefinito non può essere più lungo +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` e `maxItems` devono essere 0-{MAX_LIST}, `maxItems` almeno 1, e il valore predefinito deve averne altrettanti +@msg `name` must be a string `name` deve essere una stringa +@msg `opacity` must be a number `opacity` deve essere un numero +@msg `type` must be "path" or "compound" `type` deve essere "path" o "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` deve avere 1-{MAX_UNIT} caratteri stampabili +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` deve elencare da 1 a {MAX_CONDITION_VALUES} valori per `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` nomina `{other}`, che non è un parametro dichiarato prima di questo +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` nomina al massimo {MAX_CONDITIONS} parametri +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` accetta i valori di `{other}`, quelli di una casella di controllo o di una scelta +@msg `visible` must be true or false `visible` deve essere true o false +@msg `{key}` must be a number from {_1} to {_2} `{key}` deve essere un numero da {_1} a {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` deve essere un array di al massimo {MAX_ITEMS} voci +@msg a color default must be a color object or a hex string such as "#3a7bd5" un colore predefinito deve essere un oggetto colore o una stringa esadecimale come "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" un oggetto colore o una stringa esadecimale come "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines una sfumatura a forma libera contiene al massimo {MAX_ITEMS} punti e linee +@msg a freeform gradient line names a point it doesn't have una linea della sfumatura a forma libera nomina un punto che la sfumatura non ha +@msg a gradient needs 1-{MAX_ITEMS} stops una sfumatura richiede 1-{MAX_ITEMS} cursori colore +@msg a list of numbers un elenco di numeri +@msg a list of {min_items} to {max_items} numbers un elenco da {min_items} a {max_items} numeri +@msg a new object needs a `path` un nuovo oggetto richiede un `path` +@msg a number un numero +@msg a painted live effect returns only `objects` un effetto dinamico di pittura restituisce solo `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` un pezzo di un effetto dinamico di pittura ha solo `path`, `fillRule`, `fills` e `strokes` +@msg a read-only object was left out è stato omesso un oggetto di sola lettura +@msg a read-only object was left out: return it, as {"id"} alone or as it was given è stato omesso un oggetto di sola lettura: restituiscilo come {"id"} da solo o così come è stato fornito +@msg a string una stringa +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` una traccia ha solo `paint`, `width`, `join`, `cap` e `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} una traccia deve essere {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} un effetto deve essere {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` un effetto su un riempimento restituisce solo `fills` +@msg an effect on a stroke returns only `strokes` un effetto su una traccia restituisce solo `strokes` +@msg an object must be a JSON object un oggetto deve essere un oggetto JSON +@msg bad range intervallo non valido +@msg choices must be 1-64 non-empty, printable strings le scelte devono essere 1-64 stringhe stampabili non vuote +@msg coordinate out of range (limit ±{MAX_COORD} pt) coordinata fuori intervallo (limite ±{MAX_COORD} pt) +@msg default is not one of the options il valore predefinito non è una delle opzioni +@msg each piece needs a `path` ogni pezzo richiede un `path` +@msg is listed twice è elencato due volte +@msg pattern transform out of range trasformazione del motivo fuori intervallo +@msg read-only field '{field}' changed il campo di sola lettura '{field}' è cambiato +@msg stops must be in order of `offset` i cursori colore devono essere in ordine di `offset` +@msg the document has no pattern {pattern} il documento non ha il motivo {pattern} +@msg the document has no swatch {n} il documento non ha il campione {n} +@msg the output l’output +@msg the path has no anchor points il tracciato non ha punti di ancoraggio +@msg too many anchor points (limit {MAX_ANCHORS}) troppi punti di ancoraggio (limite {MAX_ANCHORS}) +@msg true or false true o false +@msg unknown key `{k}`; the keys here are {_1} chiave sconosciuta `{k}`; le chiavi qui sono {_1} +@msg {_1} is listed twice {_1} è elencato due volte +@msg {_1} is not the id of an input object {_1} non è l’id di un oggetto di input +@msg {_1} is read-only {_1} è di sola lettura +@msg {what} must be from {lo} to {hi} {what} deve essere compreso tra {lo} e {hi} +@msg a color component una componente colore +@msg a plate una lastra # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Installare “{name}”, che chiede queste autorizzazioni? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 65d7721aa1..8012739747 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5784,6 +5784,110 @@ axis Y Axis Y 軸 @msg a live effect runs while the document is drawn, so it can't be `heavy` ライブ効果はドキュメントの描画中に実行されるため、`heavy` にはできません @msg an exporter always gets an export's budget: leave `budget` out 書き出しプラグインには常に書き出しの予算が割り当てられます。`budget` は省略してください @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants 「{_1}」は大きな計算予算を求めています (1 回の実行につき最大 {minutes} 分、通常の {times} 倍の命令)。デスクトップ アプリで人が承認するか、--allow-plugin-grants を付けて開始したセッションで `allowHeavy: true` を指定してください +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` は印刷可能な 1-64 文字にしてください +@msg `version`, `description` or `author` is too long `version`、`description` または `author` が長すぎます +@msg `warnings` must be a list of strings `warnings` は文字列のリストにしてください +@msg `{k}` must be one of {_1} `{k}` は {_1} のいずれかにしてください +@msg `{k}` must be {what} `{k}` は {what} にしてください +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 書き出しプラグインは `extensions` を 1~{MAX_EXTENSIONS} 個宣言します +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` グループ外のパラメーターは {MAX_UNGROUPED} 個までです。ほかのパラメーターには `group` を指定してください +@msg can't encode the input 入力をエンコードできません +@msg expected {"dataBase64"} {"dataBase64"} が必要です +@msg expected {"objects"} {"objects"} が必要です +@msg export `{name}`: {e} モジュールのエクスポート `{name}`: {e} +@msg extension {e} is listed twice 拡張子 {e} が 2 回指定されています +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot 拡張子 {e}: ドットを付けずに小文字と数字で 1-16 文字にしてください +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ID {_1} は無効です。A-Z a-z 0-9 . _ - の 1-64 文字にしてください +@msg invalid parameter name {k} パラメーター名 {k} は無効です +@msg manifest is not UTF-8 マニフェストが UTF-8 ではありません +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) マニフェストが長すぎます (上限 {MAX_MANIFEST_BYTES} バイト) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) マニフェストは {_1} バイトです (上限 {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) モジュールは {_1} バイトです (上限 {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own 承認が必要です: {0} を要求しています。これはユーザーが単独でインストールするときに許可します +@msg not JSON: {e} JSON ではありません: {e} +@msg not a WebAssembly binary (missing \0asm header) WebAssembly バイナリではありません (\0asm ヘッダーがありません) +@msg parameter `{k}`: {what} パラメーター `{k}`: {what} +@msg parameter `{name}`: {what} パラメーター `{name}`: {what} +@msg plug-in parameters must be a JSON object プラグインのパラメーターは JSON オブジェクトにしてください +@msg the module must export its linear memory as `memory` モジュールは線形メモリを `memory` としてエクスポートする必要があります +@msg the objects to change are no longer in the document 変更するオブジェクトがドキュメントにもうありません +@msg the runtime panicked ランタイムが異常終了しました +@msg unexpected host trap ホストで予期しないトラップが発生しました +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version が {v} を返しました。このホストが実装するバージョンは {ABI_VERSION} です +@msg vc_alloc returned 0 (out of memory) vc_alloc が 0 を返しました (メモリ不足) +@msg vc_alloc returned a block outside memory vc_alloc がメモリの外のブロックを返しました +@msg vc_run returned error code {packed} vc_run がエラーコード {packed} を返しました +@msg {_1} is not a file {_1} はファイルではありません +@msg {_1} is {_2} bytes (limit {_3}) {_1} は {_2} バイトです (上限 {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) オブジェクトが {_1} 個あります (上限 {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} がメモリの外を指しています +@msg `angle` must be a number `angle` には数値を指定してください +@msg `aspect` must be from 0.01 to 100 `aspect` は 0.01 から 100 の範囲にしてください +@msg `cap` must be "butt", "round" or "square" `cap` は "butt"、"round"、"square" のいずれかにしてください +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` は "nonzero" または "evenodd" にしてください +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` は {MAX_HELP_URL} バイト以下の https:// アドレスにしてください +@msg `help` must be 1-{MAX_HELP} printable characters `help` は印刷可能な 1-{MAX_HELP} 文字にしてください +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` は 1-{MAX_EFFECT_ID} バイトの効果 ID にしてください +@msg `id` must be the id of an input object `id` は入力オブジェクトの ID にしてください +@msg `join` must be "miter", "round" or "bevel" `join` は "miter"、"round"、"bevel" のいずれかにしてください +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` と `group` は印刷可能な 1-{MAX_LABEL} 文字にしてください +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` は 1-{MAX_TEXT} にし、デフォルト値はそれより長くしないでください +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` と `maxItems` は 0-{MAX_LIST}、`maxItems` は 1 以上にし、デフォルト値の項目数をその範囲にしてください +@msg `name` must be a string `name` は文字列にしてください +@msg `opacity` must be a number `opacity` には数値を指定してください +@msg `type` must be "path" or "compound" `type` は "path" または "compound" にしてください +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` は印刷可能な 1-{MAX_UNIT} 文字にしてください +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` には `{other}` の値を 1 から {MAX_CONDITION_VALUES} 個指定してください +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` が指定した `{other}` は、このパラメーターより前に宣言されたパラメーターではありません +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` に指定できるパラメーターは {MAX_CONDITIONS} 個までです +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` が受け取るのは、チェックボックスか選択肢である `{other}` の値です +@msg `visible` must be true or false `visible` は true または false にしてください +@msg `{key}` must be a number from {_1} to {_2} `{key}` は {_1} から {_2} の数値にしてください +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` は {MAX_ITEMS} 項目以下の配列にしてください +@msg a color default must be a color object or a hex string such as "#3a7bd5" カラーのデフォルト値はカラーオブジェクトか "#3a7bd5" のような 16 進文字列にしてください +@msg a color object or a hex string such as "#3a7bd5" カラーオブジェクトか "#3a7bd5" のような 16 進文字列 +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines フリーフォームグラデーションのポイントとラインは {MAX_ITEMS} 個までです +@msg a freeform gradient line names a point it doesn't have フリーフォームグラデーションのラインが、グラデーションにないポイントを指定しています +@msg a gradient needs 1-{MAX_ITEMS} stops グラデーションには 1-{MAX_ITEMS} 個の分岐点が必要です +@msg a list of numbers 数値のリスト +@msg a list of {min_items} to {max_items} numbers {min_items} から {max_items} 個の数値のリスト +@msg a new object needs a `path` 新しいオブジェクトには `path` が必要です +@msg a number 数値 +@msg a painted live effect returns only `objects` ペイント型のライブ効果が返すのは `objects` だけです +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ペイント型のライブ効果のピースに使えるのは `path`、`fillRule`、`fills`、`strokes` だけです +@msg a read-only object was left out 読み取り専用のオブジェクトが省かれています +@msg a read-only object was left out: return it, as {"id"} alone or as it was given 読み取り専用のオブジェクトが省かれています: {"id"} だけの形か、渡されたとおりの形で返してください +@msg a string 文字列 +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 線に使えるのは `paint`、`width`、`join`、`cap`、`miterLimit` だけです +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 線は {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} の形にしてください +@msg an effect must be {"id", "params"?, "visible"?} 効果は {"id", "params"?, "visible"?} の形にしてください +@msg an effect on a fill returns only `fills` 塗りの効果が返すのは `fills` だけです +@msg an effect on a stroke returns only `strokes` 線の効果が返すのは `strokes` だけです +@msg an object must be a JSON object オブジェクトは JSON オブジェクトにしてください +@msg bad range 範囲が正しくありません +@msg choices must be 1-64 non-empty, printable strings 選択肢は空でない印刷可能な文字列 1-64 個にしてください +@msg coordinate out of range (limit ±{MAX_COORD} pt) 座標が範囲外です (上限 ±{MAX_COORD} pt) +@msg default is not one of the options デフォルト値が選択肢にありません +@msg each piece needs a `path` 各ピースには `path` が必要です +@msg is listed twice 2 回指定されています +@msg pattern transform out of range パターンの変形が範囲外です +@msg read-only field '{field}' changed 読み取り専用のフィールド '{field}' が変更されています +@msg stops must be in order of `offset` 分岐点は `offset` の順に並べてください +@msg the document has no pattern {pattern} ドキュメントにパターン {pattern} がありません +@msg the document has no swatch {n} ドキュメントにスウォッチ {n} がありません +@msg the output 出力 +@msg the path has no anchor points パスにアンカーポイントがありません +@msg too many anchor points (limit {MAX_ANCHORS}) アンカーポイントが多すぎます (上限 {MAX_ANCHORS}) +@msg true or false true または false +@msg unknown key `{k}`; the keys here are {_1} `{k}` は不明なキーです。ここで使えるキー: {_1} +@msg {_1} is listed twice {_1} が 2 回指定されています +@msg {_1} is not the id of an input object {_1} は入力オブジェクトの ID ではありません +@msg {_1} is read-only {_1} は読み取り専用です +@msg {what} must be from {lo} to {hi} {what} は {lo} から {hi} の範囲にしてください +@msg a color component カラー成分 +@msg a plate 版 # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? これらの権限を求める「{name}」をインストールしますか? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index eb686a14a0..6612bb4f25 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6045,6 +6045,110 @@ axis Y Axis Y ღერძი @msg a live effect runs while the document is drawn, so it can't be `heavy` ცოცხალი ეფექტი დოკუმენტის დახატვისას ეშვება, ამიტომ ის ვერ იქნება `heavy` @msg an exporter always gets an export's budget: leave `budget` out ექსპორტის პლაგინი ყოველთვის ექსპორტის ბიუჯეტს იღებს: გამოტოვეთ `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}“ დიდ გამოთვლით ბიუჯეტს ითხოვს (თითო გაშვება {minutes} წუთამდე და ჩვეულებრივზე {times}-ჯერ მეტი ინსტრუქცია): ის დესკტოპის აპში ადამიანმა უნდა დაამტკიცოს, ან გადაეცით `allowHeavy: true` --allow-plugin-grants-ით გაშვებულ სესიაში +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` უნდა შედგებოდეს 1-64 დასაბეჭდი სიმბოლოსგან +@msg `version`, `description` or `author` is too long `version`, `description` ან `author` ზედმეტად გრძელია +@msg `warnings` must be a list of strings `warnings` უნდა იყოს სტრიქონების სია +@msg `{k}` must be one of {_1} `{k}` უნდა იყოს ერთ-ერთი: {_1} +@msg `{k}` must be {what} `{k}` უნდა იყოს {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ექსპორტის პლაგინი აცხადებს 1-დან {MAX_EXTENSIONS}-მდე `extensions`-ს +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` ჯგუფების გარეთ მაქსიმუმ {MAX_UNGROUPED} პარამეტრია დასაშვები: დანარჩენებს მიეცით `group` +@msg can't encode the input შეყვანის კოდირება ვერ ხერხდება +@msg expected {"dataBase64"} მოსალოდნელი იყო {"dataBase64"} +@msg expected {"objects"} მოსალოდნელი იყო {"objects"} +@msg export `{name}`: {e} მოდულის ექსპორტი `{name}`: {e} +@msg extension {e} is listed twice გაფართოება {e} ორჯერაა მითითებული +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot გაფართოება {e}: გამოიყენეთ 1-16 პატარა ასო და ციფრი, წერტილის გარეშე +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - არასწორი ID {_1}: გამოიყენეთ 1-64 სიმბოლო A-Z a-z 0-9 . _ - ნაკრებიდან +@msg invalid parameter name {k} პარამეტრის არასწორი სახელი {k} +@msg manifest is not UTF-8 მანიფესტი UTF-8 კოდირებაში არ არის +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) მანიფესტი ზედმეტად გრძელია (ლიმიტი {MAX_MANIFEST_BYTES} ბაიტი) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) მანიფესტი {_1} ბაიტია (ლიმიტი {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) მოდული {_1} ბაიტია (ლიმიტი {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own საჭიროებს დამტკიცებას: ითხოვს {0}-ს, რასაც მომხმარებელი მისი ცალკე დაყენებისას აძლევს +@msg not JSON: {e} არ არის JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) არ არის WebAssembly-ის ბინარული ფაილი (აკლია \0asm სათაური) +@msg parameter `{k}`: {what} პარამეტრი `{k}`: {what} +@msg parameter `{name}`: {what} პარამეტრი `{name}`: {what} +@msg plug-in parameters must be a JSON object პლაგინის პარამეტრები JSON ობიექტი უნდა იყოს +@msg the module must export its linear memory as `memory` მოდულმა თავისი წრფივი მეხსიერების ექსპორტი `memory` სახელით უნდა გააკეთოს +@msg the objects to change are no longer in the document შესაცვლელი ობიექტები დოკუმენტში აღარ არის +@msg the runtime panicked გაშვების გარემო ავარიულად შეჩერდა +@msg unexpected host trap ჰოსტის მოულოდნელი ავარიული შეჩერება (trap) +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version-მა დააბრუნა {v}; ეს ჰოსტი ვერსიას {ABI_VERSION} ახორციელებს +@msg vc_alloc returned 0 (out of memory) vc_alloc-მა დააბრუნა 0 (მეხსიერება ამოიწურა) +@msg vc_alloc returned a block outside memory vc_alloc-მა დააბრუნა ბლოკი მეხსიერების გარეთ +@msg vc_run returned error code {packed} vc_run-მა დააბრუნა შეცდომის კოდი {packed} +@msg {_1} is not a file {_1} ფაილი არ არის +@msg {_1} is {_2} bytes (limit {_3}) {_1} {_2} ბაიტია (ლიმიტი {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} ობიექტი (ლიმიტი {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} მეხსიერების გარეთ მიუთითებს +@msg `angle` must be a number `angle` უნდა იყოს რიცხვი +@msg `aspect` must be from 0.01 to 100 `aspect` უნდა იყოს 0.01-დან 100-მდე +@msg `cap` must be "butt", "round" or "square" `cap` უნდა იყოს "butt", "round" ან "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` უნდა იყოს "nonzero" ან "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` უნდა იყოს https:// მისამართი, არაუმეტეს {MAX_HELP_URL} ბაიტისა +@msg `help` must be 1-{MAX_HELP} printable characters `help` უნდა შედგებოდეს 1-{MAX_HELP} დასაბეჭდი სიმბოლოსგან +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` უნდა იყოს ეფექტის 1-{MAX_EFFECT_ID} ბაიტიანი ID +@msg `id` must be the id of an input object `id` უნდა იყოს შემავალი ობიექტის ID +@msg `join` must be "miter", "round" or "bevel" `join` უნდა იყოს "miter", "round" ან "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` და `group` უნდა შედგებოდეს 1-{MAX_LABEL} დასაბეჭდი სიმბოლოსგან +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` უნდა იყოს 1-{MAX_TEXT}, ხოლო ნაგულისხმევი მნიშვნელობა არ უნდა იყოს უფრო გრძელი +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` და `maxItems` უნდა იყოს 0-{MAX_LIST}, `maxItems` მინიმუმ 1, ხოლო ნაგულისხმევ მნიშვნელობაში უნდა იყოს ამდენი ელემენტი +@msg `name` must be a string `name` უნდა იყოს სტრიქონი +@msg `opacity` must be a number `opacity` უნდა იყოს რიცხვი +@msg `type` must be "path" or "compound" `type` უნდა იყოს "path" ან "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` უნდა შედგებოდეს 1-{MAX_UNIT} დასაბეჭდი სიმბოლოსგან +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen`-ში `{other}`-ისთვის უნდა იყოს 1-დან {MAX_CONDITION_VALUES}-მდე მნიშვნელობა +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` ასახელებს `{other}`-ს, რომელიც ამაზე ადრე გამოცხადებული პარამეტრი არ არის +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` ასახელებს არაუმეტეს {MAX_CONDITIONS} პარამეტრს +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` იღებს `{other}`-ის მნიშვნელობებს, მოსანიშნი ველის ან არჩევანისას +@msg `visible` must be true or false `visible` უნდა იყოს true ან false +@msg `{key}` must be a number from {_1} to {_2} `{key}` უნდა იყოს რიცხვი {_1}-დან {_2}-მდე +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` უნდა იყოს მასივი, არაუმეტეს {MAX_ITEMS} ელემენტით +@msg a color default must be a color object or a hex string such as "#3a7bd5" ფერის ნაგულისხმევი მნიშვნელობა უნდა იყოს ფერის ობიექტი ან თექვსმეტობითი სტრიქონი, მაგალითად "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" ფერის ობიექტი ან თექვსმეტობითი სტრიქონი, მაგალითად "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines თავისუფალი ფორმის გრადიენტი შეიცავს არაუმეტეს {MAX_ITEMS} წერტილსა და ხაზს +@msg a freeform gradient line names a point it doesn't have თავისუფალი ფორმის გრადიენტის ხაზი ასახელებს წერტილს, რომელიც გრადიენტს არ აქვს +@msg a gradient needs 1-{MAX_ITEMS} stops გრადიენტს სჭირდება 1-{MAX_ITEMS} გადასვლის წერტილი +@msg a list of numbers რიცხვების სია +@msg a list of {min_items} to {max_items} numbers {min_items}-დან {max_items}-მდე რიცხვის სია +@msg a new object needs a `path` ახალ ობიექტს სჭირდება `path` +@msg a number რიცხვი +@msg a painted live effect returns only `objects` მხატვრული ცოცხალი ეფექტი აბრუნებს მხოლოდ `objects`-ს +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` მხატვრული ცოცხალი ეფექტის ნაწილს აქვს მხოლოდ `path`, `fillRule`, `fills` და `strokes` +@msg a read-only object was left out გამოტოვებულია მხოლოდ წასაკითხი ობიექტი +@msg a read-only object was left out: return it, as {"id"} alone or as it was given გამოტოვებულია მხოლოდ წასაკითხი ობიექტი: დააბრუნეთ ის მხოლოდ {"id"}-ის სახით ან ისე, როგორც მიიღეთ +@msg a string სტრიქონი +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` კონტურს აქვს მხოლოდ `paint`, `width`, `join`, `cap` და `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} კონტური უნდა იყოს {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} ეფექტი უნდა იყოს {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` შევსების ეფექტი აბრუნებს მხოლოდ `fills`-ს +@msg an effect on a stroke returns only `strokes` კონტურის ეფექტი აბრუნებს მხოლოდ `strokes`-ს +@msg an object must be a JSON object ობიექტი უნდა იყოს JSON ობიექტი +@msg bad range არასწორი დიაპაზონი +@msg choices must be 1-64 non-empty, printable strings არჩევანი უნდა იყოს 1-64 არაცარიელი, დასაბეჭდი სტრიქონი +@msg coordinate out of range (limit ±{MAX_COORD} pt) კოორდინატი დიაპაზონს გარეთაა (ლიმიტი ±{MAX_COORD} pt) +@msg default is not one of the options ნაგულისხმევი მნიშვნელობა ვარიანტებს შორის არ არის +@msg each piece needs a `path` თითოეულ ნაწილს სჭირდება `path` +@msg is listed twice ორჯერაა მითითებული +@msg pattern transform out of range პატერნის გარდაქმნა დიაპაზონს გარეთაა +@msg read-only field '{field}' changed შეიცვალა მხოლოდ წასაკითხი ველი '{field}' +@msg stops must be in order of `offset` გადასვლის წერტილები `offset`-ის მიხედვით უნდა იყოს დალაგებული +@msg the document has no pattern {pattern} დოკუმენტს არ აქვს პატერნი {pattern} +@msg the document has no swatch {n} დოკუმენტს არ აქვს ფერის ნიმუში {n} +@msg the output გამოტანა +@msg the path has no anchor points ტრაექტორიას საყრდენი წერტილები არ აქვს +@msg too many anchor points (limit {MAX_ANCHORS}) ძალიან ბევრი საყრდენი წერტილი (ლიმიტი {MAX_ANCHORS}) +@msg true or false true ან false +@msg unknown key `{k}`; the keys here are {_1} უცნობი გასაღები `{k}`; აქ გასაღებებია: {_1} +@msg {_1} is listed twice {_1} ორჯერაა მითითებული +@msg {_1} is not the id of an input object {_1} შემავალი ობიექტის ID არ არის +@msg {_1} is read-only {_1} მხოლოდ წასაკითხია +@msg {what} must be from {lo} to {hi} {what} უნდა იყოს {lo}-დან {hi}-მდე +@msg a color component ფერის კომპონენტი +@msg a plate ფირფიტა # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? დავაყენოთ „{name}“, რომელიც ამ ნებართვებს ითხოვს? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 52a10ab8e8..d4fea2ff4b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5680,6 +5680,110 @@ axis Y Axis Y축 @msg a live effect runs while the document is drawn, so it can't be `heavy` 라이브 효과는 문서를 그리는 동안 실행되므로 `heavy`일 수 없습니다 @msg an exporter always gets an export's budget: leave `budget` out 내보내기 플러그인은 항상 내보내기 예산을 받습니다. `budget`은 생략하세요 @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}”은(는) 큰 계산 예산을 요청합니다(실행당 최대 {minutes}분, 평소의 {times}배 명령어). 데스크톱 앱에서 사람이 승인하거나 --allow-plugin-grants로 시작한 세션에서 `allowHeavy: true`를 전달하세요 +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name`은(는) 인쇄 가능한 문자 1-64자여야 합니다 +@msg `version`, `description` or `author` is too long `version`, `description` 또는 `author`가 너무 깁니다 +@msg `warnings` must be a list of strings `warnings`는 문자열 목록이어야 합니다 +@msg `{k}` must be one of {_1} `{k}`은(는) {_1} 중 하나여야 합니다 +@msg `{k}` must be {what} `{k}`은(는) {what}이어야 합니다 +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 내보내기 플러그인은 `extensions`를 1~{MAX_EXTENSIONS}개 선언합니다 +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` 그룹 밖의 매개변수는 최대 {MAX_UNGROUPED}개입니다. 나머지에는 `group`을 지정하세요 +@msg can't encode the input 입력을 인코딩할 수 없습니다 +@msg expected {"dataBase64"} {"dataBase64"} 형식이어야 합니다 +@msg expected {"objects"} {"objects"} 형식이어야 합니다 +@msg export `{name}`: {e} 모듈 내보내기 `{name}`: {e} +@msg extension {e} is listed twice 확장자 {e}이(가) 두 번 나열되었습니다 +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot 확장자 {e}: 점 없이 소문자와 숫자 1-16자를 사용하세요 +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - 잘못된 ID {_1}: A-Z a-z 0-9 . _ - 중에서 1-64자를 사용하세요 +@msg invalid parameter name {k} 잘못된 매개변수 이름 {k} +@msg manifest is not UTF-8 매니페스트가 UTF-8이 아닙니다 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) 매니페스트가 너무 깁니다(한도 {MAX_MANIFEST_BYTES}바이트) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) 매니페스트가 {_1}바이트입니다(한도 {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) 모듈이 {_1}바이트입니다(한도 {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own 승인이 필요합니다. {0}을(를) 요청하며, 사용자가 따로 설치할 때 허용합니다 +@msg not JSON: {e} JSON이 아닙니다: {e} +@msg not a WebAssembly binary (missing \0asm header) WebAssembly 바이너리가 아닙니다(\0asm 헤더 없음) +@msg parameter `{k}`: {what} 매개변수 `{k}`: {what} +@msg parameter `{name}`: {what} 매개변수 `{name}`: {what} +@msg plug-in parameters must be a JSON object 플러그인 매개변수는 JSON 개체여야 합니다 +@msg the module must export its linear memory as `memory` 모듈은 선형 메모리를 `memory`(으)로 내보내야 합니다 +@msg the objects to change are no longer in the document 변경할 개체가 더 이상 문서에 없습니다 +@msg the runtime panicked 런타임이 비정상 종료되었습니다 +@msg unexpected host trap 호스트에서 예기치 않은 트랩이 발생했습니다 +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version이 {v}을(를) 반환했습니다. 이 호스트는 버전 {ABI_VERSION}을(를) 구현합니다 +@msg vc_alloc returned 0 (out of memory) vc_alloc이 0을 반환했습니다(메모리 부족) +@msg vc_alloc returned a block outside memory vc_alloc이 메모리 밖의 블록을 반환했습니다 +@msg vc_run returned error code {packed} vc_run이 오류 코드 {packed}을(를) 반환했습니다 +@msg {_1} is not a file {_1}은(는) 파일이 아닙니다 +@msg {_1} is {_2} bytes (limit {_3}) {_1}이(가) {_2}바이트입니다(한도 {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) 개체 {_1}개(한도 {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what}이(가) 메모리 밖을 가리킵니다 +@msg `angle` must be a number `angle`은(는) 숫자여야 합니다 +@msg `aspect` must be from 0.01 to 100 `aspect`은(는) 0.01에서 100 사이여야 합니다 +@msg `cap` must be "butt", "round" or "square" `cap`은(는) "butt", "round" 또는 "square"여야 합니다 +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule`은(는) "nonzero" 또는 "evenodd"여야 합니다 +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl`은(는) {MAX_HELP_URL}바이트 이하의 https:// 주소여야 합니다 +@msg `help` must be 1-{MAX_HELP} printable characters `help`은(는) 인쇄 가능한 문자 1-{MAX_HELP}자여야 합니다 +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id`은(는) 1-{MAX_EFFECT_ID}바이트의 효과 ID여야 합니다 +@msg `id` must be the id of an input object `id`은(는) 입력 개체의 ID여야 합니다 +@msg `join` must be "miter", "round" or "bevel" `join`은(는) "miter", "round" 또는 "bevel"이어야 합니다 +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label`과 `group`은 인쇄 가능한 문자 1-{MAX_LABEL}자여야 합니다 +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength`은(는) 1-{MAX_TEXT}여야 하며 기본값은 그보다 길 수 없습니다 +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems`와 `maxItems`는 0-{MAX_LIST}, `maxItems`는 1 이상이어야 하며 기본값의 항목 수도 그 범위여야 합니다 +@msg `name` must be a string `name`은(는) 문자열이어야 합니다 +@msg `opacity` must be a number `opacity`은(는) 숫자여야 합니다 +@msg `type` must be "path" or "compound" `type`은(는) "path" 또는 "compound"여야 합니다 +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit`은(는) 인쇄 가능한 문자 1-{MAX_UNIT}자여야 합니다 +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen`에는 `{other}`의 값을 1-{MAX_CONDITION_VALUES}개 나열해야 합니다 +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen`이 지정한 `{other}`은(는) 이 매개변수보다 앞에 선언된 매개변수가 아닙니다 +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen`에는 매개변수를 최대 {MAX_CONDITIONS}개까지 지정할 수 있습니다 +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen`은 확인란 또는 선택 항목인 `{other}`의 값을 받습니다 +@msg `visible` must be true or false `visible`은(는) true 또는 false여야 합니다 +@msg `{key}` must be a number from {_1} to {_2} `{key}`은(는) {_1}에서 {_2} 사이의 숫자여야 합니다 +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}`은(는) 항목이 최대 {MAX_ITEMS}개인 배열이어야 합니다 +@msg a color default must be a color object or a hex string such as "#3a7bd5" 색상 기본값은 색상 개체 또는 "#3a7bd5" 같은 16진수 문자열이어야 합니다 +@msg a color object or a hex string such as "#3a7bd5" 색상 개체 또는 "#3a7bd5" 같은 16진수 문자열 +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines 자유형 그레이디언트에는 점과 선이 최대 {MAX_ITEMS}개까지 있습니다 +@msg a freeform gradient line names a point it doesn't have 자유형 그레이디언트의 선이 그레이디언트에 없는 점을 지정합니다 +@msg a gradient needs 1-{MAX_ITEMS} stops 그레이디언트에는 정지점이 1-{MAX_ITEMS}개 필요합니다 +@msg a list of numbers 숫자 목록 +@msg a list of {min_items} to {max_items} numbers 숫자 {min_items}-{max_items}개의 목록 +@msg a new object needs a `path` 새 개체에는 `path`가 필요합니다 +@msg a number 숫자 +@msg a painted live effect returns only `objects` 페인트형 라이브 효과는 `objects`만 반환합니다 +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` 페인트형 라이브 효과의 조각에는 `path`, `fillRule`, `fills`, `strokes`만 있습니다 +@msg a read-only object was left out 읽기 전용 개체가 빠졌습니다 +@msg a read-only object was left out: return it, as {"id"} alone or as it was given 읽기 전용 개체가 빠졌습니다: {"id"}만 있는 형태나 받은 그대로 반환하십시오 +@msg a string 문자열 +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 선에는 `paint`, `width`, `join`, `cap`, `miterLimit`만 있습니다 +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 선은 {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 형태여야 합니다 +@msg an effect must be {"id", "params"?, "visible"?} 효과는 {"id", "params"?, "visible"?} 형태여야 합니다 +@msg an effect on a fill returns only `fills` 칠에 적용된 효과는 `fills`만 반환합니다 +@msg an effect on a stroke returns only `strokes` 선에 적용된 효과는 `strokes`만 반환합니다 +@msg an object must be a JSON object 개체는 JSON 개체여야 합니다 +@msg bad range 잘못된 범위 +@msg choices must be 1-64 non-empty, printable strings 선택 항목은 비어 있지 않은 인쇄 가능한 문자열 1-64개여야 합니다 +@msg coordinate out of range (limit ±{MAX_COORD} pt) 좌표가 범위를 벗어났습니다(한도 ±{MAX_COORD} pt) +@msg default is not one of the options 기본값이 옵션 중 하나가 아닙니다 +@msg each piece needs a `path` 각 조각에는 `path`가 필요합니다 +@msg is listed twice 두 번 나열되었습니다 +@msg pattern transform out of range 패턴 변형이 범위를 벗어났습니다 +@msg read-only field '{field}' changed 읽기 전용 필드 '{field}'이(가) 변경되었습니다 +@msg stops must be in order of `offset` 정지점은 `offset` 순서여야 합니다 +@msg the document has no pattern {pattern} 문서에 패턴 {pattern}이(가) 없습니다 +@msg the document has no swatch {n} 문서에 견본 {n}이(가) 없습니다 +@msg the output 출력 +@msg the path has no anchor points 패스에 앵커 포인트가 없습니다 +@msg too many anchor points (limit {MAX_ANCHORS}) 앵커 포인트가 너무 많습니다(한도 {MAX_ANCHORS}) +@msg true or false true 또는 false +@msg unknown key `{k}`; the keys here are {_1} 알 수 없는 키 `{k}`: 여기서 사용할 수 있는 키: {_1} +@msg {_1} is listed twice {_1}이(가) 두 번 나열되었습니다 +@msg {_1} is not the id of an input object {_1}은(는) 입력 개체의 ID가 아닙니다 +@msg {_1} is read-only {_1}은(는) 읽기 전용입니다 +@msg {what} must be from {lo} to {hi} {what}은(는) {lo}에서 {hi} 사이여야 합니다 +@msg a color component 색상 구성 요소 +@msg a plate 인쇄판 # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? 이 권한을 요청하는 “{name}”을(를) 설치하시겠습니까? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e44328480f..52c620c968 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5948,6 +5948,110 @@ axis Y Axis Oś Y @msg a live effect runs while the document is drawn, so it can't be `heavy` efekt na żywo działa podczas rysowania dokumentu, więc nie może być `heavy` @msg an exporter always gets an export's budget: leave `budget` out wtyczka eksportu zawsze dostaje budżet eksportu: pomiń `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}” prosi o duży budżet obliczeń (każde uruchomienie do {minutes} minut i {times} razy więcej instrukcji niż zwykle): musi ją zatwierdzić osoba w aplikacji desktopowej albo przekaż `allowHeavy: true` w sesji uruchomionej z --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` musi mieć 1-64 drukowalnych znaków +@msg `version`, `description` or `author` is too long `version`, `description` lub `author` jest za długie +@msg `warnings` must be a list of strings `warnings` musi być listą ciągów znaków +@msg `{k}` must be one of {_1} `{k}` musi mieć jedną z wartości: {_1} +@msg `{k}` must be {what} `{k}` musi być {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` wtyczka eksportu deklaruje od 1 do {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` najwyżej {MAX_UNGROUPED} parametrów poza grupami: pozostałym nadaj `group` +@msg can't encode the input nie można zakodować danych wejściowych +@msg expected {"dataBase64"} oczekiwano {"dataBase64"} +@msg expected {"objects"} oczekiwano {"objects"} +@msg export `{name}`: {e} eksport modułu `{name}`: {e} +@msg extension {e} is listed twice rozszerzenie {e} występuje dwa razy +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot rozszerzenie {e}: użyj 1-16 małych liter i cyfr, bez kropki +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - nieprawidłowe ID {_1}: użyj 1-64 znaków spośród A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} nieprawidłowa nazwa parametru {k} +@msg manifest is not UTF-8 manifest nie jest w kodowaniu UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) manifest jest za długi (limit {MAX_MANIFEST_BYTES} bajtów) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) manifest ma {_1} bajtów (limit {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) moduł ma {_1} bajtów (limit {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own wymaga zatwierdzenia: prosi o {0}, na co użytkownik zezwala, instalując ją osobno +@msg not JSON: {e} to nie jest JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) to nie jest plik binarny WebAssembly (brak nagłówka \0asm) +@msg parameter `{k}`: {what} parametr `{k}`: {what} +@msg parameter `{name}`: {what} parametr `{name}`: {what} +@msg plug-in parameters must be a JSON object parametry wtyczki muszą być obiektem JSON +@msg the module must export its linear memory as `memory` moduł musi eksportować swoją pamięć liniową jako `memory` +@msg the objects to change are no longer in the document obiektów do zmiany nie ma już w dokumencie +@msg the runtime panicked środowisko uruchomieniowe uległo awarii +@msg unexpected host trap nieoczekiwane przerwanie (trap) hosta +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version zwróciła {v}; ten host implementuje wersję {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc zwróciła 0 (brak pamięci) +@msg vc_alloc returned a block outside memory vc_alloc zwróciła blok poza pamięcią +@msg vc_run returned error code {packed} vc_run zwróciła kod błędu {packed} +@msg {_1} is not a file {_1} nie jest plikiem +@msg {_1} is {_2} bytes (limit {_3}) {_1} ma {_2} bajtów (limit {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} obiektów (limit {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} wskazuje poza pamięć +@msg `angle` must be a number `angle` musi być liczbą +@msg `aspect` must be from 0.01 to 100 `aspect` musi mieścić się w zakresie od 0.01 do 100 +@msg `cap` must be "butt", "round" or "square" `cap` musi mieć wartość "butt", "round" lub "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` musi mieć wartość "nonzero" lub "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` musi być adresem https:// o długości co najwyżej {MAX_HELP_URL} bajtów +@msg `help` must be 1-{MAX_HELP} printable characters `help` musi mieć 1-{MAX_HELP} drukowalnych znaków +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` musi być identyfikatorem efektu o długości 1-{MAX_EFFECT_ID} bajtów +@msg `id` must be the id of an input object `id` musi być identyfikatorem obiektu wejściowego +@msg `join` must be "miter", "round" or "bevel" `join` musi mieć wartość "miter", "round" lub "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` i `group` muszą mieć 1-{MAX_LABEL} drukowalnych znaków +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` musi wynosić 1-{MAX_TEXT}, a wartość domyślna nie może być dłuższa +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` i `maxItems` muszą wynosić 0-{MAX_LIST}, `maxItems` co najmniej 1, a wartość domyślna musi mieć tyle elementów +@msg `name` must be a string `name` musi być ciągiem znaków +@msg `opacity` must be a number `opacity` musi być liczbą +@msg `type` must be "path" or "compound" `type` musi mieć wartość "path" lub "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` musi mieć 1-{MAX_UNIT} drukowalnych znaków +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` musi podawać od 1 do {MAX_CONDITION_VALUES} wartości dla `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` wskazuje `{other}`, który nie jest parametrem zadeklarowanym przed tym +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` wskazuje co najwyżej {MAX_CONDITIONS} parametrów +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` przyjmuje wartości `{other}`, czyli pola wyboru lub listy opcji +@msg `visible` must be true or false `visible` musi mieć wartość true lub false +@msg `{key}` must be a number from {_1} to {_2} `{key}` musi być liczbą od {_1} do {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` musi być tablicą o co najwyżej {MAX_ITEMS} elementach +@msg a color default must be a color object or a hex string such as "#3a7bd5" domyślny kolor musi być obiektem koloru lub ciągiem szesnastkowym, np. "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" obiektem koloru lub ciągiem szesnastkowym, np. "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines gradient dowolny zawiera co najwyżej {MAX_ITEMS} punktów i linii +@msg a freeform gradient line names a point it doesn't have linia gradientu dowolnego wskazuje punkt, którego gradient nie ma +@msg a gradient needs 1-{MAX_ITEMS} stops gradient wymaga 1-{MAX_ITEMS} punktów koloru +@msg a list of numbers listą liczb +@msg a list of {min_items} to {max_items} numbers listą od {min_items} do {max_items} liczb +@msg a new object needs a `path` nowy obiekt wymaga `path` +@msg a number liczbą +@msg a painted live effect returns only `objects` malujący efekt dynamiczny zwraca tylko `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` element malującego efektu dynamicznego ma tylko `path`, `fillRule`, `fills` i `strokes` +@msg a read-only object was left out pominięto obiekt tylko do odczytu +@msg a read-only object was left out: return it, as {"id"} alone or as it was given pominięto obiekt tylko do odczytu: zwróć go jako samo {"id"} lub w postaci, w jakiej został przekazany +@msg a string ciągiem znaków +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` obrys ma tylko `paint`, `width`, `join`, `cap` i `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} obrys musi mieć postać {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} efekt musi mieć postać {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` efekt wypełnienia zwraca tylko `fills` +@msg an effect on a stroke returns only `strokes` efekt obrysu zwraca tylko `strokes` +@msg an object must be a JSON object obiekt musi być obiektem JSON +@msg bad range nieprawidłowy zakres +@msg choices must be 1-64 non-empty, printable strings opcje muszą być 1-64 niepustymi, drukowalnymi ciągami znaków +@msg coordinate out of range (limit ±{MAX_COORD} pt) współrzędna spoza zakresu (limit ±{MAX_COORD} pt) +@msg default is not one of the options wartość domyślna nie jest jedną z opcji +@msg each piece needs a `path` każdy element wymaga `path` +@msg is listed twice występuje dwa razy +@msg pattern transform out of range przekształcenie wzorku spoza zakresu +@msg read-only field '{field}' changed zmieniono pole tylko do odczytu '{field}' +@msg stops must be in order of `offset` punkty koloru muszą być uporządkowane według `offset` +@msg the document has no pattern {pattern} dokument nie ma wzorku {pattern} +@msg the document has no swatch {n} dokument nie ma próbki {n} +@msg the output wyjście +@msg the path has no anchor points ścieżka nie ma punktów kontrolnych +@msg too many anchor points (limit {MAX_ANCHORS}) za dużo punktów kontrolnych (limit {MAX_ANCHORS}) +@msg true or false true lub false +@msg unknown key `{k}`; the keys here are {_1} nieznany klucz `{k}`; klucze w tym miejscu to {_1} +@msg {_1} is listed twice {_1} występuje dwa razy +@msg {_1} is not the id of an input object {_1} nie jest identyfikatorem obiektu wejściowego +@msg {_1} is read-only {_1} jest tylko do odczytu +@msg {what} must be from {lo} to {hi} {what} musi mieścić się w zakresie od {lo} do {hi} +@msg a color component składowa koloru +@msg a plate płyta # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Zainstalować „{name}”, wtyczkę, która prosi o te uprawnienia? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3ce111f7a4..5360d938a9 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6009,6 +6009,110 @@ axis Y Axis Eixo Y @msg a live effect runs while the document is drawn, so it can't be `heavy` um efeito dinâmico é executado enquanto o documento é desenhado, então não pode ser `heavy` @msg an exporter always gets an export's budget: leave `budget` out um plug-in de exportação sempre recebe o orçamento de uma exportação: omita `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” pede um orçamento de processamento grande (cada execução até {minutes} minutos e {times} vezes as instruções habituais): uma pessoa precisa aprová-lo no aplicativo de desktop, ou passe `allowHeavy: true` em uma sessão iniciada com --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` deve ter 1-64 caracteres imprimíveis +@msg `version`, `description` or `author` is too long `version`, `description` ou `author` é longo demais +@msg `warnings` must be a list of strings `warnings` deve ser uma lista de strings +@msg `{k}` must be one of {_1} `{k}` deve ser um de {_1} +@msg `{k}` must be {what} `{k}` deve ser {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` um plug-in de exportação declara de 1 a {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` no máximo {MAX_UNGROUPED} parâmetros fora de grupos: dê um `group` aos outros +@msg can't encode the input não é possível codificar a entrada +@msg expected {"dataBase64"} esperado {"dataBase64"} +@msg expected {"objects"} esperado {"objects"} +@msg export `{name}`: {e} exportação do módulo `{name}`: {e} +@msg extension {e} is listed twice a extensão {e} aparece duas vezes +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensão {e}: use 1-16 letras minúsculas e dígitos, sem o ponto +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ID inválido {_1}: use 1-64 caracteres de A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} nome de parâmetro inválido {k} +@msg manifest is not UTF-8 o manifesto não está em UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) o manifesto é longo demais (limite {MAX_MANIFEST_BYTES} bytes) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) o manifesto tem {_1} bytes (limite {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) o módulo tem {_1} bytes (limite {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own precisa de aprovação: pede {0}, que o usuário concede ao instalá-lo separadamente +@msg not JSON: {e} não é JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) não é um binário WebAssembly (falta o cabeçalho \0asm) +@msg parameter `{k}`: {what} parâmetro `{k}`: {what} +@msg parameter `{name}`: {what} parâmetro `{name}`: {what} +@msg plug-in parameters must be a JSON object os parâmetros do plug-in devem ser um objeto JSON +@msg the module must export its linear memory as `memory` o módulo deve exportar sua memória linear como `memory` +@msg the objects to change are no longer in the document os objetos a alterar não estão mais no documento +@msg the runtime panicked o ambiente de execução falhou +@msg unexpected host trap trap inesperada do host +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version retornou {v}; este host implementa a versão {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc retornou 0 (sem memória) +@msg vc_alloc returned a block outside memory vc_alloc retornou um bloco fora da memória +@msg vc_run returned error code {packed} vc_run retornou o código de erro {packed} +@msg {_1} is not a file {_1} não é um arquivo +@msg {_1} is {_2} bytes (limit {_3}) {_1} tem {_2} bytes (limite {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objetos (limite {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} aponta para fora da memória +@msg `angle` must be a number `angle` deve ser um número +@msg `aspect` must be from 0.01 to 100 `aspect` deve estar entre 0.01 e 100 +@msg `cap` must be "butt", "round" or "square" `cap` deve ser "butt", "round" ou "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` deve ser "nonzero" ou "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` deve ser um endereço https:// de no máximo {MAX_HELP_URL} bytes +@msg `help` must be 1-{MAX_HELP} printable characters `help` deve ter 1-{MAX_HELP} caracteres imprimíveis +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` deve ser um id de efeito de 1-{MAX_EFFECT_ID} bytes +@msg `id` must be the id of an input object `id` deve ser o id de um objeto de entrada +@msg `join` must be "miter", "round" or "bevel" `join` deve ser "miter", "round" ou "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` e `group` devem ter 1-{MAX_LABEL} caracteres imprimíveis +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` deve ser 1-{MAX_TEXT}, e o valor padrão não pode ser mais longo +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` e `maxItems` devem ser 0-{MAX_LIST}, `maxItems` pelo menos 1, e o valor padrão deve ter essa quantidade +@msg `name` must be a string `name` deve ser uma string +@msg `opacity` must be a number `opacity` deve ser um número +@msg `type` must be "path" or "compound" `type` deve ser "path" ou "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` deve ter 1-{MAX_UNIT} caracteres imprimíveis +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` deve listar de 1 a {MAX_CONDITION_VALUES} valores para `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` nomeia `{other}`, que não é um parâmetro declarado antes deste +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` nomeia no máximo {MAX_CONDITIONS} parâmetros +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` aceita os valores de `{other}`, de uma caixa de seleção ou de uma escolha +@msg `visible` must be true or false `visible` deve ser true ou false +@msg `{key}` must be a number from {_1} to {_2} `{key}` deve ser um número de {_1} a {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` deve ser uma matriz de no máximo {MAX_ITEMS} entradas +@msg a color default must be a color object or a hex string such as "#3a7bd5" uma cor padrão deve ser um objeto de cor ou uma string hexadecimal como "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" um objeto de cor ou uma string hexadecimal como "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines um gradiente de forma livre contém no máximo {MAX_ITEMS} pontos e linhas +@msg a freeform gradient line names a point it doesn't have uma linha do gradiente de forma livre nomeia um ponto que o gradiente não tem +@msg a gradient needs 1-{MAX_ITEMS} stops um gradiente precisa de 1-{MAX_ITEMS} paradas +@msg a list of numbers uma lista de números +@msg a list of {min_items} to {max_items} numbers uma lista de {min_items} a {max_items} números +@msg a new object needs a `path` um objeto novo precisa de um `path` +@msg a number um número +@msg a painted live effect returns only `objects` um efeito dinâmico de pintura retorna apenas `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` uma peça de um efeito dinâmico de pintura tem apenas `path`, `fillRule`, `fills` e `strokes` +@msg a read-only object was left out um objeto somente leitura foi omitido +@msg a read-only object was left out: return it, as {"id"} alone or as it was given um objeto somente leitura foi omitido: retorne-o como {"id"} sozinho ou como foi recebido +@msg a string uma string +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` um traçado tem apenas `paint`, `width`, `join`, `cap` e `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} um traçado deve ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} um efeito deve ser {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` um efeito em um preenchimento retorna apenas `fills` +@msg an effect on a stroke returns only `strokes` um efeito em um traçado retorna apenas `strokes` +@msg an object must be a JSON object um objeto deve ser um objeto JSON +@msg bad range intervalo inválido +@msg choices must be 1-64 non-empty, printable strings as opções devem ser 1-64 strings imprimíveis não vazias +@msg coordinate out of range (limit ±{MAX_COORD} pt) coordenada fora do intervalo (limite ±{MAX_COORD} pt) +@msg default is not one of the options o valor padrão não é uma das opções +@msg each piece needs a `path` cada peça precisa de um `path` +@msg is listed twice aparece duas vezes +@msg pattern transform out of range transformação do padrão fora do intervalo +@msg read-only field '{field}' changed o campo somente leitura '{field}' foi alterado +@msg stops must be in order of `offset` as paradas devem estar em ordem de `offset` +@msg the document has no pattern {pattern} o documento não tem o padrão {pattern} +@msg the document has no swatch {n} o documento não tem a amostra {n} +@msg the output a saída +@msg the path has no anchor points o caminho não tem pontos de ancoragem +@msg too many anchor points (limit {MAX_ANCHORS}) pontos de ancoragem demais (limite {MAX_ANCHORS}) +@msg true or false true ou false +@msg unknown key `{k}`; the keys here are {_1} chave desconhecida `{k}`; as chaves aqui são {_1} +@msg {_1} is listed twice {_1} aparece duas vezes +@msg {_1} is not the id of an input object {_1} não é o id de um objeto de entrada +@msg {_1} is read-only {_1} é somente leitura +@msg {what} must be from {lo} to {hi} {what} deve estar entre {lo} e {hi} +@msg a color component um componente de cor +@msg a plate uma placa # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Instalar “{name}”, que pede estas permissões? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c93807c08a..9b83cadb0b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5705,6 +5705,110 @@ axis Y Axis Ось Y @msg a live effect runs while the document is drawn, so it can't be `heavy` динамический эффект выполняется во время отрисовки документа, поэтому он не может быть `heavy` @msg an exporter always gets an export's budget: leave `budget` out модуль экспорта всегда получает бюджет экспорта: не указывайте `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants «{_1}» запрашивает большой вычислительный бюджет (каждый запуск до {minutes} минут и в {times} раз больше инструкций, чем обычно): его должен одобрить человек в настольном приложении, либо передайте `allowHeavy: true` в сеансе, запущенном с --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` должен содержать 1-64 печатаемых символа +@msg `version`, `description` or `author` is too long `version`, `description` или `author` слишком длинное +@msg `warnings` must be a list of strings `warnings` должно быть списком строк +@msg `{k}` must be one of {_1} `{k}` должно быть одним из: {_1} +@msg `{k}` must be {what} `{k}` должно быть: {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` модуль экспорта объявляет от 1 до {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` вне групп может быть не более {MAX_UNGROUPED} параметров: задайте остальным `group` +@msg can't encode the input не удаётся закодировать входные данные +@msg expected {"dataBase64"} ожидалось {"dataBase64"} +@msg expected {"objects"} ожидалось {"objects"} +@msg export `{name}`: {e} экспорт модуля `{name}`: {e} +@msg extension {e} is listed twice расширение {e} указано дважды +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot расширение {e}: используйте 1-16 строчных букв и цифр, без точки +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - недопустимый ID {_1}: используйте 1-64 символа из A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} недопустимое имя параметра {k} +@msg manifest is not UTF-8 манифест не в кодировке UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) манифест слишком длинный (предел {MAX_MANIFEST_BYTES} байт) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) манифест занимает {_1} байт (предел {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) модуль занимает {_1} байт (предел {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own требуется одобрение: он запрашивает {0}, что пользователь разрешает при отдельной установке +@msg not JSON: {e} не JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) не двоичный файл WebAssembly (нет заголовка \0asm) +@msg parameter `{k}`: {what} параметр `{k}`: {what} +@msg parameter `{name}`: {what} параметр `{name}`: {what} +@msg plug-in parameters must be a JSON object параметры модуля должны быть объектом JSON +@msg the module must export its linear memory as `memory` модуль должен экспортировать свою линейную память как `memory` +@msg the objects to change are no longer in the document изменяемых объектов больше нет в документе +@msg the runtime panicked среда выполнения аварийно завершилась +@msg unexpected host trap непредвиденная ловушка (trap) хоста +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version вернула {v}; этот хост реализует версию {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc вернула 0 (нехватка памяти) +@msg vc_alloc returned a block outside memory vc_alloc вернула блок вне памяти +@msg vc_run returned error code {packed} vc_run вернула код ошибки {packed} +@msg {_1} is not a file {_1} не является файлом +@msg {_1} is {_2} bytes (limit {_3}) {_1} занимает {_2} байт (предел {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) объектов: {_1} (предел {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} указывает за пределы памяти +@msg `angle` must be a number `angle` должно быть числом +@msg `aspect` must be from 0.01 to 100 `aspect` должно быть от 0.01 до 100 +@msg `cap` must be "butt", "round" or "square" `cap` должно быть "butt", "round" или "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` должно быть "nonzero" или "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` должно быть адресом https:// длиной не более {MAX_HELP_URL} байт +@msg `help` must be 1-{MAX_HELP} printable characters `help` должно содержать 1-{MAX_HELP} печатаемых символов +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` должно быть идентификатором эффекта длиной 1-{MAX_EFFECT_ID} байт +@msg `id` must be the id of an input object `id` должно быть идентификатором входного объекта +@msg `join` must be "miter", "round" or "bevel" `join` должно быть "miter", "round" или "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` и `group` должны содержать 1-{MAX_LABEL} печатаемых символов +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` должно быть 1-{MAX_TEXT}, а значение по умолчанию не длиннее +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` и `maxItems` должны быть 0-{MAX_LIST}, `maxItems` не меньше 1, а значение по умолчанию должно содержать столько элементов +@msg `name` must be a string `name` должно быть строкой +@msg `opacity` must be a number `opacity` должно быть числом +@msg `type` must be "path" or "compound" `type` должно быть "path" или "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` должно содержать 1-{MAX_UNIT} печатаемых символов +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` должно перечислять от 1 до {MAX_CONDITION_VALUES} значений для `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` указывает `{other}`, но это не параметр, объявленный раньше этого +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` указывает не более {MAX_CONDITIONS} параметров +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` принимает значения `{other}`, флажка или списка вариантов +@msg `visible` must be true or false `visible` должно быть true или false +@msg `{key}` must be a number from {_1} to {_2} `{key}` должно быть числом от {_1} до {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` должно быть массивом не более чем из {MAX_ITEMS} элементов +@msg a color default must be a color object or a hex string such as "#3a7bd5" цвет по умолчанию должен быть объектом цвета или шестнадцатеричной строкой, например "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" объект цвета или шестнадцатеричная строка, например "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines произвольный градиент содержит не более {MAX_ITEMS} точек и линий +@msg a freeform gradient line names a point it doesn't have линия произвольного градиента указывает точку, которой в градиенте нет +@msg a gradient needs 1-{MAX_ITEMS} stops градиенту нужно 1-{MAX_ITEMS} точек перехода +@msg a list of numbers список чисел +@msg a list of {min_items} to {max_items} numbers список из {min_items}-{max_items} чисел +@msg a new object needs a `path` новому объекту нужен `path` +@msg a number число +@msg a painted live effect returns only `objects` рисующий динамический эффект возвращает только `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` у части рисующего динамического эффекта есть только `path`, `fillRule`, `fills` и `strokes` +@msg a read-only object was left out пропущен объект только для чтения +@msg a read-only object was left out: return it, as {"id"} alone or as it was given пропущен объект только для чтения: верните его как один {"id"} или в том виде, в каком он был передан +@msg a string строка +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` у обводки есть только `paint`, `width`, `join`, `cap` и `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} обводка должна иметь вид {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} эффект должен иметь вид {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` эффект на заливке возвращает только `fills` +@msg an effect on a stroke returns only `strokes` эффект на обводке возвращает только `strokes` +@msg an object must be a JSON object объект должен быть объектом JSON +@msg bad range недопустимый диапазон +@msg choices must be 1-64 non-empty, printable strings варианты должны быть 1-64 непустыми печатаемыми строками +@msg coordinate out of range (limit ±{MAX_COORD} pt) координата вне диапазона (предел ±{MAX_COORD} pt) +@msg default is not one of the options значение по умолчанию не входит в варианты +@msg each piece needs a `path` каждой части нужен `path` +@msg is listed twice указан дважды +@msg pattern transform out of range преобразование узора вне диапазона +@msg read-only field '{field}' changed изменено поле только для чтения '{field}' +@msg stops must be in order of `offset` точки перехода должны идти в порядке `offset` +@msg the document has no pattern {pattern} в документе нет узора {pattern} +@msg the document has no swatch {n} в документе нет образца {n} +@msg the output вывод +@msg the path has no anchor points у контура нет опорных точек +@msg too many anchor points (limit {MAX_ANCHORS}) слишком много опорных точек (предел {MAX_ANCHORS}) +@msg true or false true или false +@msg unknown key `{k}`; the keys here are {_1} неизвестный ключ `{k}`; здесь допустимы ключи {_1} +@msg {_1} is listed twice {_1} указан дважды +@msg {_1} is not the id of an input object {_1} не является идентификатором входного объекта +@msg {_1} is read-only {_1} доступен только для чтения +@msg {what} must be from {lo} to {hi} {what}: значение должно быть от {lo} до {hi} +@msg a color component компонент цвета +@msg a plate пластина # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Установить «{name}», которому нужны эти разрешения? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ce20b6c72d..7bb05a80b2 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6116,6 +6116,74 @@ axis Y Axis Y-axel @msg a live effect runs while the document is drawn, so it can't be `heavy` en live-effekt körs medan dokumentet ritas, så den kan inte vara `heavy` @msg an exporter always gets an export's budget: leave `budget` out ett exportinsticksprogram får alltid en exports budget: utelämna `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants ”{_1}” begär en stor beräkningsbudget (varje körning upp till {minutes} minuter och {times} gånger de vanliga instruktionerna): en person måste godkänna det i skrivbordsappen, eller skicka `allowHeavy: true` i en session som startats med --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `{k}` must be one of {_1} `{k}` måste vara något av {_1} +@msg needs approval: it asks for {0}, which the user grants when installing it on its own kräver godkännande: det begär {0}, som användaren beviljar när pluginet installeras för sig +@msg `angle` must be a number `angle` måste vara ett tal +@msg `aspect` must be from 0.01 to 100 `aspect` måste vara från 0.01 till 100 +@msg `cap` must be "butt", "round" or "square" `cap` måste vara "butt", "round" eller "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` måste vara "nonzero" eller "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` måste vara en https://-adress på högst {MAX_HELP_URL} byte +@msg `help` must be 1-{MAX_HELP} printable characters `help` måste ha 1-{MAX_HELP} utskrivbara tecken +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` måste vara ett effekt-id på 1-{MAX_EFFECT_ID} byte +@msg `id` must be the id of an input object `id` måste vara id:t för ett indataobjekt +@msg `join` must be "miter", "round" or "bevel" `join` måste vara "miter", "round" eller "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` och `group` måste ha 1-{MAX_LABEL} utskrivbara tecken +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` måste vara 1-{MAX_TEXT}, och standardvärdet får inte vara längre +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` och `maxItems` måste vara 0-{MAX_LIST}, `maxItems` minst 1, och standardvärdet måste ha så många poster +@msg `name` must be a string `name` måste vara en sträng +@msg `opacity` must be a number `opacity` måste vara ett tal +@msg `type` must be "path" or "compound" `type` måste vara "path" eller "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` måste ha 1-{MAX_UNIT} utskrivbara tecken +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` måste ange 1 till {MAX_CONDITION_VALUES} värden för `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` anger `{other}`, som inte är en parameter som deklarerats före den här +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` anger högst {MAX_CONDITIONS} parametrar +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` tar värden från `{other}`, en kryssruta eller ett val +@msg `visible` must be true or false `visible` måste vara true eller false +@msg `{key}` must be a number from {_1} to {_2} `{key}` måste vara ett tal från {_1} till {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` måste vara en array med högst {MAX_ITEMS} poster +@msg a color default must be a color object or a hex string such as "#3a7bd5" ett standardvärde för färg måste vara ett färgobjekt eller en hexsträng som "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" ett färgobjekt eller en hexsträng som "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines en fri gradient innehåller högst {MAX_ITEMS} punkter och linjer +@msg a freeform gradient line names a point it doesn't have en linje i en fri gradient anger en punkt som gradienten inte har +@msg a gradient needs 1-{MAX_ITEMS} stops en gradient behöver 1-{MAX_ITEMS} stopp +@msg a list of numbers en lista med tal +@msg a list of {min_items} to {max_items} numbers en lista med {min_items} till {max_items} tal +@msg a new object needs a `path` ett nytt objekt behöver en `path` +@msg a number ett tal +@msg a painted live effect returns only `objects` en målande live-effekt returnerar bara `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` en del av en målande live-effekt har bara `path`, `fillRule`, `fills` och `strokes` +@msg a read-only object was left out ett skrivskyddat objekt utelämnades +@msg a read-only object was left out: return it, as {"id"} alone or as it was given ett skrivskyddat objekt utelämnades: returnera det som enbart {"id"} eller som det gavs +@msg a string en sträng +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` en kontur har bara `paint`, `width`, `join`, `cap` och `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} en kontur måste vara {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} en effekt måste vara {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` en effekt på en fyllning returnerar bara `fills` +@msg an effect on a stroke returns only `strokes` en effekt på en kontur returnerar bara `strokes` +@msg an object must be a JSON object ett objekt måste vara ett JSON-objekt +@msg bad range ogiltigt intervall +@msg choices must be 1-64 non-empty, printable strings valen måste vara 1-64 icke-tomma, utskrivbara strängar +@msg coordinate out of range (limit ±{MAX_COORD} pt) koordinat utanför intervallet (gräns ±{MAX_COORD} pt) +@msg default is not one of the options standardvärdet är inte ett av alternativen +@msg each piece needs a `path` varje del behöver en `path` +@msg is listed twice finns två gånger +@msg pattern transform out of range mönstertransformering utanför intervallet +@msg read-only field '{field}' changed det skrivskyddade fältet '{field}' ändrades +@msg stops must be in order of `offset` stoppen måste vara ordnade efter `offset` +@msg the document has no pattern {pattern} dokumentet har inget mönster {pattern} +@msg the document has no swatch {n} dokumentet har ingen färgruta {n} +@msg the output utdata +@msg the path has no anchor points banan har inga ankarpunkter +@msg too many anchor points (limit {MAX_ANCHORS}) för många ankarpunkter (gräns {MAX_ANCHORS}) +@msg true or false true eller false +@msg unknown key `{k}`; the keys here are {_1} okänd nyckel `{k}`; nycklarna här är {_1} +@msg {_1} is listed twice {_1} finns två gånger +@msg {_1} is not the id of an input object {_1} är inte id:t för ett indataobjekt +@msg {_1} is read-only {_1} är skrivskyddat +@msg {what} must be from {lo} to {hi} {what} måste vara från {lo} till {hi} +@msg a color component en färgkomponent +@msg a plate en plåt # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Installera ”{name}”, som begär dessa behörigheter? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index ade1efa8be..28e4e407bd 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1718,6 +1718,21 @@ fn package_status_message_templates_parse() { assert!(Catalog::parse("@msg\tPackaged {n} file(s) in {folder}{note}\tbad\n").message("Packaged 1 file(s) in /a b").is_none()); } +/// A template that starts with text matches a message it wraps before a template that starts +/// with a placeholder, even one with more literal text, so the wrapped reason is translated by +/// its own row. +#[test] +fn templates_that_start_with_text_match_first() { + let c = Catalog::parse(concat!( + "@msg\tplug-in manifest: {0}\twrapped {0}\n", + "@msg\t{_1} is {_2} bytes (limit {_3})\tsized {_1}\n", + "@msg\tmanifest is {_1} bytes (limit {_2})\tmanifest {_1}\n", + )); + let (form, caps) = c.message("plug-in manifest: manifest is 9 bytes (limit 8)").expect("matches"); + assert_eq!((form, caps), ("wrapped {0}", vec![("0", "manifest is 9 bytes (limit 8)")])); + assert_eq!(c.message("a.wasm is 9 bytes (limit 8)").map(|(f, _)| f), Some("sized {_1}")); +} + /// A `@msgplural` row translates both English forms of a message with a count, and the count /// picks the language's form. #[test] @@ -1748,6 +1763,86 @@ fn warped_type_note_is_translated() { } } +/// The plug-in host's status and error messages (manifests, modules, runs and the output of +/// importers, exporters and effects) have an `@msg` row in every complete-message catalog. +#[test] +fn plugin_messages_are_translated_in_every_complete_catalog() { + let messages = message_literals_in(&["plugins"]); + assert!(messages.len() > 100, "scan found only {} plug-in messages", messages.len()); + let mut missing = vec![]; + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let (entries, _) = parse_entries(lang.0.source); + let rows: std::collections::BTreeSet<&str> = entries.iter().filter(|(ctx, _, _)| ctx == "@msg").map(|(_, src, _)| src.as_str()).collect(); + missing.extend(messages.iter().filter(|m| !rows.contains(m.as_str())).map(|m| format!("{code}: {m}"))); + } + assert!(missing.is_empty(), "{} plug-in messages have no @msg row:\n{}", missing.len(), missing.join("\n")); +} + +/// Errors the plug-in host returns are translated whole in every complete-message catalog, the +/// reason inside the error's kind included, and keep their numbers, JSON shapes, permission names +/// and nested reasons as written. +#[test] +fn plugin_errors_translate_with_their_values() { + use vectorcraft_plugins::{Error, Manifest, manifest::MAX_MANIFEST_BYTES}; + let err = |r: Result<(), Error>| r.err().map(|e| e.to_string()).unwrap_or_default(); + let not_json = err(vectorcraft_plugins::export::decode(b"{} x").map(drop)); + let reason = not_json.strip_prefix("plug-in output: not JSON: ").expect("not JSON error").to_string(); + let errors = [ + (err(Manifest::parse(b"\xff").map(drop)), "manifest is not UTF-8", vec!["UTF-8"]), + (err(Manifest::parse(&vec![b' '; MAX_MANIFEST_BYTES + 1]).map(drop)), "manifest is", vec!["65537", "65536"]), + (err(vectorcraft_plugins::export::decode(b"{}").map(drop)), "expected {", vec![r#"{"dataBase64": "…"}"#]), + (err(vectorcraft_plugins::objects::decode(b"{}").map(drop)), "expected {", vec![r#"{"objects": [...]}"#]), + (not_json, "not JSON", vec![reason.as_str()]), + (Error::NeedsApproval("doc.read, doc.write".into()).to_string(), "needs approval", vec!["doc.read, doc.write"]), + ]; + for (e, english, _) in &errors { + assert!(e.contains(english), "{e}"); + } + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + for (e, english, kept) in &errors { + let shown = message(lang, e); + assert!(!shown.contains(english), "{code}: {e} -> {shown}"); + assert!(kept.iter().all(|k| shown.contains(k)), "{code}: {e} -> {shown}"); + } + } + assert_eq!(message(de(), &errors[1].0), "Plug-in-Manifest: das Manifest ist 65537 Byte groß (Grenze 65536)"); + let fr = Lang::from_code("fr").expect("registered"); + assert!(message(fr, &errors[3].0).ends_with(r#"attendu : {"objects": [...]}"#), "{}", message(fr, &errors[3].0)); +} + +/// The reasons the plug-in host puts inside its errors (what is wrong with a parameter's spec or +/// dialog keys, the value a parameter needs, what is wrong with an output object) are translated +/// with the error in every complete-message catalog, so no status line mixes two languages. +#[test] +fn plugin_error_reasons_are_translated_with_the_error() { + use serde_json::json; + use vectorcraft_plugins::{Error, Manifest}; + let err = |r: Result<(), Error>| r.err().map(|e| e.to_string()).unwrap_or_default(); + let manifest = |params: &str| Manifest::parse(format!(r#"{{"id":"a","name":"x","kind":"filter","params":{{{params}}}}}"#).as_bytes()); + let m = manifest(r#""angles":{"type":"numberList","min":0,"max":360,"default":[45],"minItems":1,"maxItems":3}"#).expect("manifest"); + let errors = [ + (err(manifest(r#""r":{"type":"number","min":5,"max":1,"default":1}"#).map(drop)), "bad range"), + (err(manifest(r#""r":{"type":"number","min":0,"max":1,"default":1,"label":" "}"#).map(drop)), "printable characters"), + (err(m.resolve_params(&json!({"angles": "big"})).map(drop)), "a list of numbers"), + (err(vectorcraft_plugins::objects::decode(br#"{"objects":[{"opacity":"full"}]}"#).map(drop)), "must be a number"), + ]; + for (e, english) in &errors { + assert!(e.contains(english), "{e}"); + } + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + for (e, english) in &errors { + let shown = message(lang, e); + assert!(!shown.contains(english), "{code}: {e} -> {shown}"); + } + } + assert_eq!(message(de(), &errors[0].0), "Plug-in-Manifest: Parameter `r`: ungültiger Bereich"); + assert_eq!(message(de(), &errors[2].0), "Plug-in-Parameter: `angles` muss eine Liste von Zahlen sein"); + assert!(message(de(), &errors[3].0).ends_with("objects[0]: `opacity` muss eine Zahl sein"), "{}", message(de(), &errors[3].0)); +} + /// The Gamut Check status line names a count of 1 in the singular and other counts in the plural, /// and every complete catalog translates it, in the form its plural rule picks. #[test] @@ -2003,6 +2098,53 @@ fn run() -> Result<()> { assert!(source_messages(code, &[], &Default::default()).is_empty()); } +/// The scan reads the message of each of the plug-in host's [`ERROR_HELPERS`]: a literal, a +/// `format!` string with or without a `&` before it, after the location of `out_err` (a +/// `format!` string too), the number and range of `in_range`, the number and location of `unit`, +/// and the block of `.read_packed`, a method. It does not read a value that isn't a literal, a +/// key's name alone, a call with too few arguments, a helper's declaration or a call whose name +/// ends in a helper's name. +#[test] +fn message_scan_reads_plugin_helpers() { + let code = r#" +fn check(k: &str) -> Result<()> { + let bad = |what: &str| Err(Error::Manifest(format!("parameter `{k}`: {what}"))); + let wrong = |what: &str| Error::Params(format!("`{k}` must be {what}")); + let _ = bad("bad range"); + let _ = bad(&format!("`maxLength` must be 1-{MAX_TEXT}, and the default no longer")); + let _ = wrong("a number"); + let _ = wrong(&format!("a list of {min_items} to {max_items} numbers")); + let _ = out_err(at, "`opacity` must be a number"); + let _ = out_err(&format!("{at}.{k}"), format!("unknown key `{k}`; the keys here are {}", keys.join(", "))); + let _ = out_err(at, e); + let _ = live.read_packed(packed, "the output", limits.max_output_bytes); + let _ = in_range(l, 0.0, 100.0, at, "a color component"); + let _ = unit(*x, &format!("{at}.plates"), "a plate"); + let _ = unit(s.tint, &format!("{at}.tint"), "`tint`"); + let _ = unit(r); + let _ = not_bad("not an error helper"); + Err(wrong()) +} +fn wrong(&self) -> String { String::new() } +"#; + let plugins: Vec<_> = ERROR_HELPERS.iter().filter(|(c, _, _)| *c == "plugins").map(|&(_, call, before)| (call, before)).collect(); + let found: std::collections::BTreeSet = source_messages(code, &plugins, &Default::default()).into_iter().collect(); + let want = [ + "`maxLength` must be 1-{MAX_TEXT}, and the default no longer", + "`opacity` must be a number", + "`{k}` must be {what}", + "a color component", + "a list of {min_items} to {max_items} numbers", + "a number", + "a plate", + "bad range", + "parameter `{k}`: {what}", + "the output", + "unknown key `{k}`; the keys here are {_1}", + ]; + assert_eq!(found.iter().collect::>(), want); +} + /// The limits a plug-in run can exceed, and the error of a removal that leaves the plug-in's grant /// in the plug-in store, are translated in every language that translates every message, as the /// status bar shows them. @@ -2747,10 +2889,25 @@ const MESSAGE_MARKERS: &[&str] = &[".status(", "ui.status = ", "status = ", "Oth /// Helpers that make an error from a message, by crate, with the number of arguments before the /// message: the engine's `bad(cmd, msg)` (`EngineError::BadParams`), `err(msg)` (`cmd/fileio`) -/// and `error(msg)` (`cmd/dimension.rs`), and the PDF writer's `bad(why)` (`forms.rs`) and -/// `failed(why)` (`encrypt.rs`, `linearize.rs`). -const ERROR_HELPERS: &[(&str, &str, usize)] = - &[("engine", "bad(", 1), ("engine", "err(", 0), ("engine", "error(", 0), ("pdf", "bad(", 0), ("pdf", "failed(", 0)]; +/// and `error(msg)` (`cmd/dimension.rs`), the PDF writer's `bad(why)` (`forms.rs`) and +/// `failed(why)` (`encrypt.rs`, `linearize.rs`), and the plug-in host's `bad(what)` (a parameter's +/// spec and dialog keys), `wrong(what)` (a parameter's value), `out_err(at, msg)` (plug-in output), +/// `in_range(x, lo, hi, at, what)` and `unit(x, at, what)` (a painted effect's numbers) and +/// `.read_packed(packed, what, max)` (the block a plug-in returned). A helper that starts with +/// `.` is a method. +const ERROR_HELPERS: &[(&str, &str, usize)] = &[ + ("engine", "bad(", 1), + ("engine", "err(", 0), + ("engine", "error(", 0), + ("pdf", "bad(", 0), + ("pdf", "failed(", 0), + ("plugins", "bad(", 0), + ("plugins", "wrong(", 0), + ("plugins", "out_err(", 1), + ("plugins", "in_range(", 4), + ("plugins", "unit(", 2), + ("plugins", ".read_packed(", 1), +]; /// Error variants whose first argument is a name, not a message: a command id /// (`EngineError::UnknownCommand`) and a PostScript error name (`PsError::Ps`). @@ -2760,16 +2917,22 @@ const NAME_VARIANTS: &[&str] = &["UnknownCommand", "Ps"]; /// `EngineError::Disabled(command id, reason)`. const LATER_MESSAGE_VARIANTS: &[(&str, usize)] = &[("Disabled", 1)]; -/// Messages that are signals, not text for people. -const NOT_MESSAGES: &[&str] = &["quit", "cancelled"]; +/// Messages that are signals, not text for people, and the name of a plug-in export that an +/// error message holds (`vc_manifest points outside memory`). +const NOT_MESSAGES: &[&str] = &["quit", "cancelled", "vc_manifest"]; /// Every status and error message literal in [`MESSAGE_CRATES`] (test modules aside), as catalog /// templates: `{}` becomes `{_1}`, `{_2}` … and format specs are dropped (`{n:?}` → `{n}`). pub fn message_literals() -> std::collections::BTreeSet { + message_literals_in(MESSAGE_CRATES) +} + +/// The message literals of [`message_literals`] in the crates `names` alone. +fn message_literals_in(names: &[&str]) -> std::collections::BTreeSet { let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); let mut out = std::collections::BTreeSet::new(); let mut files = vec![]; - let mut stack: Vec<_> = MESSAGE_CRATES.iter().map(|c| crates.join(c).join("src")).collect(); + let mut stack: Vec<_> = names.iter().map(|c| crates.join(c).join("src")).collect(); while let Some(d) = stack.pop() { for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() { let path = entry.path(); @@ -2835,7 +2998,8 @@ fn named_const<'a>(rest: &str, consts: &'a std::collections::BTreeMap>) -> Vec { let code = &without_comments(code); let after_markers = MESSAGE_MARKERS.iter().flat_map(|marker| code.match_indices(marker).map(|(at, _)| &code[at + marker.len()..])); @@ -2848,12 +3012,13 @@ fn source_messages(code: &str, helpers: &[(&str, usize)], consts: &std::collecti }); let in_helpers = helpers.iter().flat_map(|&(call, before)| { code.match_indices(call) - .filter(|&(at, _)| !code[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.')) + .filter(|&(at, _)| call.starts_with('.') || !code[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.')) .filter_map(move |(at, _)| skip_args(&code[at + call.len()..], before)) }); let mut out = vec![]; for rest in after_markers.chain(in_variants).chain(in_helpers) { let rest = rest.trim_start(); + let rest = rest.strip_prefix('&').map_or(rest, str::trim_start); let rest = rest.strip_prefix("format!(").map_or(rest, str::trim_start); let lits = match rest.strip_prefix('"') { Some(quoted) => string_literal(quoted).into_iter().collect(), @@ -2861,7 +3026,8 @@ fn source_messages(code: &str, helpers: &[(&str, usize)], consts: &std::collecti }; for lit in lits { let has_word = lit.as_bytes().windows(3).any(|w| w.iter().all(u8::is_ascii_lowercase)); - if has_word && !NOT_MESSAGES.contains(&lit.as_str()) { + let key_name = lit.len() > 1 && lit.starts_with('`') && lit.ends_with('`') && !lit.contains(' '); + if has_word && !key_name && !NOT_MESSAGES.contains(&lit.as_str()) { out.push(as_template(&lit)); } } diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index db3c4501b7..5abc7569e6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6050,6 +6050,110 @@ axis Y Axis Y ekseni @msg a live effect runs while the document is drawn, so it can't be `heavy` canlı efekt belge çizilirken çalışır, bu yüzden `heavy` olamaz @msg an exporter always gets an export's budget: leave `budget` out dışa aktarma eklentisi her zaman bir dışa aktarmanın bütçesini alır: `budget` anahtarını çıkarın @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” büyük bir hesaplama bütçesi istiyor (her çalıştırma en fazla {minutes} dakika ve olağan komutların {times} katı): masaüstü uygulamasında bir kişinin onaylaması gerekir veya --allow-plugin-grants ile başlatılan bir oturumda `allowHeavy: true` geçirin +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` 1-64 yazdırılabilir karakter olmalıdır +@msg `version`, `description` or `author` is too long `version`, `description` veya `author` çok uzun +@msg `warnings` must be a list of strings `warnings` bir dize listesi olmalıdır +@msg `{k}` must be one of {_1} `{k}` şunlardan biri olmalıdır: {_1} +@msg `{k}` must be {what} `{k}` şu olmalıdır: {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` dışa aktarma eklentisi 1 ile {MAX_EXTENSIONS} arasında `extensions` bildirir +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` gruplar dışında en fazla {MAX_UNGROUPED} parametre olabilir: diğerlerine bir `group` verin +@msg can't encode the input girdi kodlanamıyor +@msg expected {"dataBase64"} beklenen: {"dataBase64"} +@msg expected {"objects"} beklenen: {"objects"} +@msg export `{name}`: {e} modül dışa aktarımı `{name}`: {e} +@msg extension {e} is listed twice {e} uzantısı iki kez listelenmiş +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot {e} uzantısı: nokta olmadan 1-16 küçük harf ve rakam kullanın +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - geçersiz kimlik {_1}: A-Z a-z 0-9 . _ - arasından 1-64 karakter kullanın +@msg invalid parameter name {k} geçersiz parametre adı {k} +@msg manifest is not UTF-8 bildirim UTF-8 değil +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) bildirim çok uzun (sınır {MAX_MANIFEST_BYTES} bayt) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) bildirim {_1} bayt (sınır {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) modül {_1} bayt (sınır {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own onay gerekiyor: {0} istiyor; kullanıcı bunu eklentiyi tek başına yüklerken verir +@msg not JSON: {e} JSON değil: {e} +@msg not a WebAssembly binary (missing \0asm header) WebAssembly ikili dosyası değil (\0asm üst bilgisi eksik) +@msg parameter `{k}`: {what} `{k}` parametresi: {what} +@msg parameter `{name}`: {what} `{name}` parametresi: {what} +@msg plug-in parameters must be a JSON object eklenti parametreleri bir JSON nesnesi olmalıdır +@msg the module must export its linear memory as `memory` modül doğrusal belleğini `memory` olarak dışa aktarmalıdır +@msg the objects to change are no longer in the document değiştirilecek nesneler artık belgede değil +@msg the runtime panicked çalışma zamanı çöktü +@msg unexpected host trap ana bilgisayarda beklenmeyen kesme (trap) +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version {v} döndürdü; bu ana bilgisayar {ABI_VERSION} sürümünü uygular +@msg vc_alloc returned 0 (out of memory) vc_alloc 0 döndürdü (bellek yetersiz) +@msg vc_alloc returned a block outside memory vc_alloc bellek dışında bir blok döndürdü +@msg vc_run returned error code {packed} vc_run {packed} hata kodunu döndürdü +@msg {_1} is not a file {_1} bir dosya değil +@msg {_1} is {_2} bytes (limit {_3}) {_1} {_2} bayt (sınır {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} nesne (sınır {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} bellek dışını gösteriyor +@msg `angle` must be a number `angle` bir sayı olmalıdır +@msg `aspect` must be from 0.01 to 100 `aspect` 0.01 ile 100 arasında olmalıdır +@msg `cap` must be "butt", "round" or "square" `cap` "butt", "round" veya "square" olmalıdır +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` "nonzero" veya "evenodd" olmalıdır +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` en fazla {MAX_HELP_URL} baytlık bir https:// adresi olmalıdır +@msg `help` must be 1-{MAX_HELP} printable characters `help` 1-{MAX_HELP} yazdırılabilir karakter olmalıdır +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` 1-{MAX_EFFECT_ID} baytlık bir efekt kimliği olmalıdır +@msg `id` must be the id of an input object `id` bir girdi nesnesinin kimliği olmalıdır +@msg `join` must be "miter", "round" or "bevel" `join` "miter", "round" veya "bevel" olmalıdır +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` ve `group` 1-{MAX_LABEL} yazdırılabilir karakter olmalıdır +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` 1-{MAX_TEXT} olmalı ve varsayılan değer bundan uzun olmamalıdır +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` ve `maxItems` 0-{MAX_LIST} olmalı, `maxItems` en az 1 olmalı ve varsayılan değer bu kadar öğe içermelidir +@msg `name` must be a string `name` bir dize olmalıdır +@msg `opacity` must be a number `opacity` bir sayı olmalıdır +@msg `type` must be "path" or "compound" `type` "path" veya "compound" olmalıdır +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` 1-{MAX_UNIT} yazdırılabilir karakter olmalıdır +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen`, `{other}` için 1 ile {MAX_CONDITION_VALUES} arasında değer listelemelidir +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` `{other}` adını veriyor, ancak bu, bundan önce bildirilmiş bir parametre değil +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` en fazla {MAX_CONDITIONS} parametre adı verir +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen`, bir onay kutusu veya seçim olan `{other}` parametresinin değerlerini alır +@msg `visible` must be true or false `visible` true veya false olmalıdır +@msg `{key}` must be a number from {_1} to {_2} `{key}` {_1} ile {_2} arasında bir sayı olmalıdır +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` en fazla {MAX_ITEMS} öğeli bir dizi olmalıdır +@msg a color default must be a color object or a hex string such as "#3a7bd5" varsayılan renk bir renk nesnesi veya "#3a7bd5" gibi onaltılık bir dize olmalıdır +@msg a color object or a hex string such as "#3a7bd5" bir renk nesnesi veya "#3a7bd5" gibi onaltılık bir dize +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines serbest biçimli degrade en fazla {MAX_ITEMS} nokta ve çizgi içerir +@msg a freeform gradient line names a point it doesn't have serbest biçimli degradenin bir çizgisi, degradede olmayan bir noktayı belirtiyor +@msg a gradient needs 1-{MAX_ITEMS} stops bir degradenin 1-{MAX_ITEMS} durağa ihtiyacı vardır +@msg a list of numbers bir sayı listesi +@msg a list of {min_items} to {max_items} numbers {min_items} ile {max_items} arasında sayıdan oluşan bir liste +@msg a new object needs a `path` yeni bir nesnenin `path` değerine ihtiyacı vardır +@msg a number bir sayı +@msg a painted live effect returns only `objects` boyama türü canlı efekt yalnızca `objects` döndürür +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` boyama türü canlı efektin bir parçasında yalnızca `path`, `fillRule`, `fills` ve `strokes` bulunur +@msg a read-only object was left out salt okunur bir nesne atlandı +@msg a read-only object was left out: return it, as {"id"} alone or as it was given salt okunur bir nesne atlandı: onu yalnızca {"id"} olarak veya verildiği gibi döndürün +@msg a string bir dize +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` bir konturda yalnızca `paint`, `width`, `join`, `cap` ve `miterLimit` bulunur +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} bir kontur {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} biçiminde olmalıdır +@msg an effect must be {"id", "params"?, "visible"?} bir efekt {"id", "params"?, "visible"?} biçiminde olmalıdır +@msg an effect on a fill returns only `fills` dolgudaki bir efekt yalnızca `fills` döndürür +@msg an effect on a stroke returns only `strokes` konturdaki bir efekt yalnızca `strokes` döndürür +@msg an object must be a JSON object bir nesne bir JSON nesnesi olmalıdır +@msg bad range geçersiz aralık +@msg choices must be 1-64 non-empty, printable strings seçenekler 1-64 boş olmayan, yazdırılabilir dize olmalıdır +@msg coordinate out of range (limit ±{MAX_COORD} pt) koordinat aralık dışında (sınır ±{MAX_COORD} pt) +@msg default is not one of the options varsayılan değer seçeneklerden biri değil +@msg each piece needs a `path` her parçanın `path` değerine ihtiyacı vardır +@msg is listed twice iki kez listelenmiş +@msg pattern transform out of range desen dönüşümü aralık dışında +@msg read-only field '{field}' changed salt okunur '{field}' alanı değişti +@msg stops must be in order of `offset` duraklar `offset` sırasında olmalıdır +@msg the document has no pattern {pattern} belgede {pattern} deseni yok +@msg the document has no swatch {n} belgede {n} renk örneği yok +@msg the output çıktı +@msg the path has no anchor points yolun bağlantı noktası yok +@msg too many anchor points (limit {MAX_ANCHORS}) çok fazla bağlantı noktası (sınır {MAX_ANCHORS}) +@msg true or false true veya false +@msg unknown key `{k}`; the keys here are {_1} bilinmeyen anahtar `{k}`; buradaki anahtarlar: {_1} +@msg {_1} is listed twice {_1} iki kez listelenmiş +@msg {_1} is not the id of an input object {_1} bir girdi nesnesinin kimliği değil +@msg {_1} is read-only {_1} salt okunurdur +@msg {what} must be from {lo} to {hi} {what} {lo} ile {hi} arasında olmalıdır +@msg a color component bir renk bileşeni +@msg a plate bir baskı plakası # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Bu izinleri isteyen “{name}” yüklensin mi? ID: {id} Kimlik: {id} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 151a886cf9..7f54787eeb 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5699,6 +5699,110 @@ axis Y Axis Вісь Y @msg a live effect runs while the document is drawn, so it can't be `heavy` динамічний ефект виконується під час відмальовування документа, тому він не може бути `heavy` @msg an exporter always gets an export's budget: leave `budget` out модуль експорту завжди отримує бюджет експорту: не вказуйте `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants «{_1}» запитує великий обчислювальний бюджет (кожен запуск до {minutes} хвилин і в {times} разів більше інструкцій, ніж зазвичай): його має схвалити людина в настільному застосунку, або передайте `allowHeavy: true` у сеансі, запущеному з --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` має містити 1-64 друковані символи +@msg `version`, `description` or `author` is too long `version`, `description` або `author` задовге +@msg `warnings` must be a list of strings `warnings` має бути списком рядків +@msg `{k}` must be one of {_1} `{k}` має бути одним з: {_1} +@msg `{k}` must be {what} `{k}` має бути: {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` модуль експорту оголошує від 1 до {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` поза групами може бути не більше {MAX_UNGROUPED} параметрів: задайте решті `group` +@msg can't encode the input не вдається закодувати вхідні дані +@msg expected {"dataBase64"} очікувалося {"dataBase64"} +@msg expected {"objects"} очікувалося {"objects"} +@msg export `{name}`: {e} експорт модуля `{name}`: {e} +@msg extension {e} is listed twice розширення {e} вказано двічі +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot розширення {e}: використовуйте 1-16 малих літер і цифр, без крапки +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - неприпустимий ID {_1}: використовуйте 1-64 символи з A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} неприпустиме ім’я параметра {k} +@msg manifest is not UTF-8 маніфест не в кодуванні UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) маніфест задовгий (межа {MAX_MANIFEST_BYTES} байтів) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) маніфест займає {_1} байтів (межа {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) модуль займає {_1} байтів (межа {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own потрібне схвалення: він запитує {0}, що користувач дозволяє під час окремого встановлення +@msg not JSON: {e} не JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) не двійковий файл WebAssembly (немає заголовка \0asm) +@msg parameter `{k}`: {what} параметр `{k}`: {what} +@msg parameter `{name}`: {what} параметр `{name}`: {what} +@msg plug-in parameters must be a JSON object параметри модуля мають бути об’єктом JSON +@msg the module must export its linear memory as `memory` модуль має експортувати свою лінійну пам’ять як `memory` +@msg the objects to change are no longer in the document об’єктів, які треба змінити, більше немає в документі +@msg the runtime panicked середовище виконання аварійно завершилося +@msg unexpected host trap неочікувана пастка (trap) хоста +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version повернула {v}; цей хост реалізує версію {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc повернула 0 (бракує пам’яті) +@msg vc_alloc returned a block outside memory vc_alloc повернула блок поза пам’яттю +@msg vc_run returned error code {packed} vc_run повернула код помилки {packed} +@msg {_1} is not a file {_1} не є файлом +@msg {_1} is {_2} bytes (limit {_3}) {_1} займає {_2} байтів (межа {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) об’єктів: {_1} (межа {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} вказує за межі пам’яті +@msg `angle` must be a number `angle` має бути числом +@msg `aspect` must be from 0.01 to 100 `aspect` має бути від 0.01 до 100 +@msg `cap` must be "butt", "round" or "square" `cap` має бути "butt", "round" або "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` має бути "nonzero" або "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` має бути адресою https:// довжиною не більше {MAX_HELP_URL} байтів +@msg `help` must be 1-{MAX_HELP} printable characters `help` має містити 1-{MAX_HELP} друкованих символів +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` має бути ідентифікатором ефекту довжиною 1-{MAX_EFFECT_ID} байтів +@msg `id` must be the id of an input object `id` має бути ідентифікатором вхідного об’єкта +@msg `join` must be "miter", "round" or "bevel" `join` має бути "miter", "round" або "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` і `group` мають містити 1-{MAX_LABEL} друкованих символів +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` має бути 1-{MAX_TEXT}, а типове значення не довшим +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` і `maxItems` мають бути 0-{MAX_LIST}, `maxItems` щонайменше 1, а типове значення має містити стільки елементів +@msg `name` must be a string `name` має бути рядком +@msg `opacity` must be a number `opacity` має бути числом +@msg `type` must be "path" or "compound" `type` має бути "path" або "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` має містити 1-{MAX_UNIT} друкованих символів +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` має перелічувати від 1 до {MAX_CONDITION_VALUES} значень для `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` указує `{other}`, але це не параметр, оголошений раніше за цей +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` указує не більше {MAX_CONDITIONS} параметрів +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` приймає значення `{other}`, прапорця або списку варіантів +@msg `visible` must be true or false `visible` має бути true або false +@msg `{key}` must be a number from {_1} to {_2} `{key}` має бути числом від {_1} до {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` має бути масивом не більше ніж із {MAX_ITEMS} елементів +@msg a color default must be a color object or a hex string such as "#3a7bd5" типовий колір має бути об’єктом кольору або шістнадцятковим рядком, наприклад "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" об’єкт кольору або шістнадцятковий рядок, наприклад "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines довільний градієнт містить не більше {MAX_ITEMS} точок і ліній +@msg a freeform gradient line names a point it doesn't have лінія довільного градієнта вказує точку, якої в градієнті немає +@msg a gradient needs 1-{MAX_ITEMS} stops градієнту потрібно 1-{MAX_ITEMS} точок переходу +@msg a list of numbers список чисел +@msg a list of {min_items} to {max_items} numbers список із {min_items}-{max_items} чисел +@msg a new object needs a `path` новому об’єкту потрібен `path` +@msg a number число +@msg a painted live effect returns only `objects` малювальний динамічний ефект повертає лише `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` частина малювального динамічного ефекту має лише `path`, `fillRule`, `fills` і `strokes` +@msg a read-only object was left out пропущено об’єкт лише для читання +@msg a read-only object was left out: return it, as {"id"} alone or as it was given пропущено об’єкт лише для читання: поверніть його як один {"id"} або в тому вигляді, у якому його передано +@msg a string рядок +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` обведення має лише `paint`, `width`, `join`, `cap` і `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} обведення має мати вигляд {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} ефект має мати вигляд {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` ефект на заливці повертає лише `fills` +@msg an effect on a stroke returns only `strokes` ефект на обведенні повертає лише `strokes` +@msg an object must be a JSON object об’єкт має бути об’єктом JSON +@msg bad range неприпустимий діапазон +@msg choices must be 1-64 non-empty, printable strings варіанти мають бути 1-64 непорожніми друкованими рядками +@msg coordinate out of range (limit ±{MAX_COORD} pt) координата поза діапазоном (межа ±{MAX_COORD} pt) +@msg default is not one of the options типове значення не входить до варіантів +@msg each piece needs a `path` кожній частині потрібен `path` +@msg is listed twice вказано двічі +@msg pattern transform out of range перетворення візерунка поза діапазоном +@msg read-only field '{field}' changed змінено поле лише для читання '{field}' +@msg stops must be in order of `offset` точки переходу мають іти в порядку `offset` +@msg the document has no pattern {pattern} у документі немає візерунка {pattern} +@msg the document has no swatch {n} у документі немає зразка {n} +@msg the output виведення +@msg the path has no anchor points контур не має опорних точок +@msg too many anchor points (limit {MAX_ANCHORS}) забагато опорних точок (межа {MAX_ANCHORS}) +@msg true or false true або false +@msg unknown key `{k}`; the keys here are {_1} невідомий ключ `{k}`; тут допустимі ключі {_1} +@msg {_1} is listed twice {_1} вказано двічі +@msg {_1} is not the id of an input object {_1} не є ідентифікатором вхідного об’єкта +@msg {_1} is read-only {_1} доступний лише для читання +@msg {what} must be from {lo} to {hi} {what}: значення має бути від {lo} до {hi} +@msg a color component компонент кольору +@msg a plate пластина # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Встановити «{name}», якому потрібні ці дозволи? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f3b505c648..8100163fe4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5700,6 +5700,110 @@ axis Y Axis Trục Y @msg a live effect runs while the document is drawn, so it can't be `heavy` hiệu ứng trực tiếp chạy trong lúc tài liệu được vẽ nên không thể là `heavy` @msg an exporter always gets an export's budget: leave `budget` out plug-in xuất luôn nhận ngân sách của một lần xuất: hãy bỏ `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” yêu cầu ngân sách tính toán lớn (mỗi lần chạy tới {minutes} phút và gấp {times} lần số lệnh thông thường): cần một người phê duyệt trong ứng dụng máy tính, hoặc truyền `allowHeavy: true` trong phiên khởi động bằng --allow-plugin-grants +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` phải có 1-64 ký tự in được +@msg `version`, `description` or `author` is too long `version`, `description` hoặc `author` quá dài +@msg `warnings` must be a list of strings `warnings` phải là một danh sách chuỗi +@msg `{k}` must be one of {_1} `{k}` phải là một trong {_1} +@msg `{k}` must be {what} `{k}` phải là {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` plug-in xuất khai báo từ 1 đến {MAX_EXTENSIONS} `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` tối đa {MAX_UNGROUPED} tham số nằm ngoài nhóm: hãy đặt `group` cho các tham số còn lại +@msg can't encode the input không thể mã hóa dữ liệu đầu vào +@msg expected {"dataBase64"} cần có {"dataBase64"} +@msg expected {"objects"} cần có {"objects"} +@msg export `{name}`: {e} export `{name}` của mô-đun: {e} +@msg extension {e} is listed twice phần mở rộng {e} được liệt kê hai lần +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot phần mở rộng {e}: dùng 1-16 chữ thường và chữ số, không có dấu chấm +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ID không hợp lệ {_1}: dùng 1-64 ký tự trong A-Z a-z 0-9 . _ - +@msg invalid parameter name {k} tên tham số không hợp lệ {k} +@msg manifest is not UTF-8 manifest không phải UTF-8 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) manifest quá dài (giới hạn {MAX_MANIFEST_BYTES} byte) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) manifest có {_1} byte (giới hạn {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) mô-đun có {_1} byte (giới hạn {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own cần phê duyệt: plug-in yêu cầu {0}, quyền mà người dùng cấp khi cài đặt riêng plug-in đó +@msg not JSON: {e} không phải JSON: {e} +@msg not a WebAssembly binary (missing \0asm header) không phải tệp nhị phân WebAssembly (thiếu phần đầu \0asm) +@msg parameter `{k}`: {what} tham số `{k}`: {what} +@msg parameter `{name}`: {what} tham số `{name}`: {what} +@msg plug-in parameters must be a JSON object tham số plug-in phải là một đối tượng JSON +@msg the module must export its linear memory as `memory` mô-đun phải export bộ nhớ tuyến tính của nó dưới tên `memory` +@msg the objects to change are no longer in the document các đối tượng cần thay đổi không còn trong tài liệu +@msg the runtime panicked môi trường chạy bị lỗi nghiêm trọng +@msg unexpected host trap máy chủ gặp trap không mong muốn +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version trả về {v}; máy chủ này triển khai phiên bản {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc trả về 0 (hết bộ nhớ) +@msg vc_alloc returned a block outside memory vc_alloc trả về một khối nằm ngoài bộ nhớ +@msg vc_run returned error code {packed} vc_run trả về mã lỗi {packed} +@msg {_1} is not a file {_1} không phải là tệp +@msg {_1} is {_2} bytes (limit {_3}) {_1} có {_2} byte (giới hạn {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} đối tượng (giới hạn {MAX_OBJECTS}) +@msg {at}: {msg} {at}: {msg} +@msg {what} points outside memory {what} trỏ ra ngoài bộ nhớ +@msg `angle` must be a number `angle` phải là một số +@msg `aspect` must be from 0.01 to 100 `aspect` phải từ 0.01 đến 100 +@msg `cap` must be "butt", "round" or "square" `cap` phải là "butt", "round" hoặc "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` phải là "nonzero" hoặc "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` phải là một địa chỉ https:// dài tối đa {MAX_HELP_URL} byte +@msg `help` must be 1-{MAX_HELP} printable characters `help` phải có 1-{MAX_HELP} ký tự in được +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` phải là một id hiệu ứng dài 1-{MAX_EFFECT_ID} byte +@msg `id` must be the id of an input object `id` phải là id của một đối tượng đầu vào +@msg `join` must be "miter", "round" or "bevel" `join` phải là "miter", "round" hoặc "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` và `group` phải có 1-{MAX_LABEL} ký tự in được +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` phải là 1-{MAX_TEXT}, và giá trị mặc định không được dài hơn +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` và `maxItems` phải là 0-{MAX_LIST}, `maxItems` ít nhất là 1, và giá trị mặc định phải có đúng số mục đó +@msg `name` must be a string `name` phải là một chuỗi +@msg `opacity` must be a number `opacity` phải là một số +@msg `type` must be "path" or "compound" `type` phải là "path" hoặc "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` phải có 1-{MAX_UNIT} ký tự in được +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` phải liệt kê 1 đến {MAX_CONDITION_VALUES} giá trị cho `{other}` +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` nêu `{other}`, nhưng đó không phải là tham số được khai báo trước tham số này +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` nêu tối đa {MAX_CONDITIONS} tham số +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` nhận giá trị của `{other}`, là một hộp kiểm hoặc một lựa chọn +@msg `visible` must be true or false `visible` phải là true hoặc false +@msg `{key}` must be a number from {_1} to {_2} `{key}` phải là một số từ {_1} đến {_2} +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` phải là một mảng có tối đa {MAX_ITEMS} mục +@msg a color default must be a color object or a hex string such as "#3a7bd5" màu mặc định phải là một đối tượng màu hoặc một chuỗi hex như "#3a7bd5" +@msg a color object or a hex string such as "#3a7bd5" một đối tượng màu hoặc một chuỗi hex như "#3a7bd5" +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines chuyển sắc tự do chứa tối đa {MAX_ITEMS} điểm và đường +@msg a freeform gradient line names a point it doesn't have một đường của chuyển sắc tự do nêu một điểm mà chuyển sắc không có +@msg a gradient needs 1-{MAX_ITEMS} stops chuyển sắc cần 1-{MAX_ITEMS} điểm dừng +@msg a list of numbers một danh sách số +@msg a list of {min_items} to {max_items} numbers một danh sách gồm {min_items} đến {max_items} số +@msg a new object needs a `path` đối tượng mới cần một `path` +@msg a number một số +@msg a painted live effect returns only `objects` hiệu ứng động kiểu tô chỉ trả về `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` một phần của hiệu ứng động kiểu tô chỉ có `path`, `fillRule`, `fills` và `strokes` +@msg a read-only object was left out một đối tượng chỉ đọc đã bị bỏ sót +@msg a read-only object was left out: return it, as {"id"} alone or as it was given một đối tượng chỉ đọc đã bị bỏ sót: hãy trả lại nó dưới dạng chỉ {"id"} hoặc như khi được nhận +@msg a string một chuỗi +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` nét chỉ có `paint`, `width`, `join`, `cap` và `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} nét phải có dạng {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} +@msg an effect must be {"id", "params"?, "visible"?} hiệu ứng phải có dạng {"id", "params"?, "visible"?} +@msg an effect on a fill returns only `fills` hiệu ứng trên phần tô chỉ trả về `fills` +@msg an effect on a stroke returns only `strokes` hiệu ứng trên nét chỉ trả về `strokes` +@msg an object must be a JSON object đối tượng phải là một đối tượng JSON +@msg bad range phạm vi không hợp lệ +@msg choices must be 1-64 non-empty, printable strings các lựa chọn phải là 1-64 chuỗi in được và không rỗng +@msg coordinate out of range (limit ±{MAX_COORD} pt) tọa độ vượt ngoài phạm vi (giới hạn ±{MAX_COORD} pt) +@msg default is not one of the options giá trị mặc định không nằm trong các tùy chọn +@msg each piece needs a `path` mỗi phần cần một `path` +@msg is listed twice được liệt kê hai lần +@msg pattern transform out of range phép biến đổi họa tiết vượt ngoài phạm vi +@msg read-only field '{field}' changed trường chỉ đọc '{field}' đã bị thay đổi +@msg stops must be in order of `offset` các điểm dừng phải theo thứ tự của `offset` +@msg the document has no pattern {pattern} tài liệu không có họa tiết {pattern} +@msg the document has no swatch {n} tài liệu không có mẫu màu {n} +@msg the output đầu ra +@msg the path has no anchor points đường dẫn không có điểm neo +@msg too many anchor points (limit {MAX_ANCHORS}) quá nhiều điểm neo (giới hạn {MAX_ANCHORS}) +@msg true or false true hoặc false +@msg unknown key `{k}`; the keys here are {_1} khóa không xác định `{k}`; các khóa ở đây là {_1} +@msg {_1} is listed twice {_1} được liệt kê hai lần +@msg {_1} is not the id of an input object {_1} không phải là id của một đối tượng đầu vào +@msg {_1} is read-only {_1} là chỉ đọc +@msg {what} must be from {lo} to {hi} {what} phải từ {lo} đến {hi} +@msg a color component một thành phần màu +@msg a plate một bản in # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? Cài đặt “{name}”, plug-in yêu cầu các quyền này? ID: {id} ID: {id} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c7219b9448..5c7c64a976 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5932,6 +5932,110 @@ axis Y Axis Y 轴 @msg a live effect runs while the document is drawn, so it can't be `heavy` 实时效果在绘制文档时运行,因此不能为 `heavy` @msg an exporter always gets an export's budget: leave `budget` out 导出增效工具始终使用导出的预算:请省略 `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}”需要大量计算预算(每次运行最多 {minutes} 分钟,指令数为通常的 {times} 倍):需要有人在桌面应用中批准,或在以 --allow-plugin-grants 启动的会话中传入 `allowHeavy: true` +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` 必须是 1-64 个可打印字符 +@msg `version`, `description` or `author` is too long `version`、`description` 或 `author` 太长 +@msg `warnings` must be a list of strings `warnings` 必须是字符串列表 +@msg `{k}` must be one of {_1} `{k}` 必须是 {_1} 之一 +@msg `{k}` must be {what} `{k}` 必须是 {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 导出增效工具声明 1 到 {MAX_EXTENSIONS} 个 `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` 组外最多 {MAX_UNGROUPED} 个参数:请为其余参数指定 `group` +@msg can't encode the input 无法编码输入 +@msg expected {"dataBase64"} 应为 {"dataBase64"} +@msg expected {"objects"} 应为 {"objects"} +@msg export `{name}`: {e} 模块导出 `{name}`:{e} +@msg extension {e} is listed twice 扩展名 {e} 列出了两次 +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot 扩展名 {e}:请使用 1-16 个小写字母和数字,不带点 +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - 无效的 ID {_1}:请使用 1-64 个 A-Z a-z 0-9 . _ - 中的字符 +@msg invalid parameter name {k} 无效的参数名称 {k} +@msg manifest is not UTF-8 清单不是 UTF-8 编码 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) 清单太长(上限 {MAX_MANIFEST_BYTES} 字节) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) 清单为 {_1} 字节(上限 {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) 模块为 {_1} 字节(上限 {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own 需要批准:它请求 {0},用户单独安装它时授予这些权限 +@msg not JSON: {e} 不是 JSON:{e} +@msg not a WebAssembly binary (missing \0asm header) 不是 WebAssembly 二进制文件(缺少 \0asm 头) +@msg parameter `{k}`: {what} 参数 `{k}`:{what} +@msg parameter `{name}`: {what} 参数 `{name}`:{what} +@msg plug-in parameters must be a JSON object 增效工具参数必须是 JSON 对象 +@msg the module must export its linear memory as `memory` 模块必须将其线性内存导出为 `memory` +@msg the objects to change are no longer in the document 要更改的对象已不在文档中 +@msg the runtime panicked 运行时崩溃 +@msg unexpected host trap 宿主发生意外的 trap +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version 返回了 {v};此宿主实现的是版本 {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc 返回了 0(内存不足) +@msg vc_alloc returned a block outside memory vc_alloc 返回了内存之外的块 +@msg vc_run returned error code {packed} vc_run 返回了错误代码 {packed} +@msg {_1} is not a file {_1} 不是文件 +@msg {_1} is {_2} bytes (limit {_3}) {_1} 为 {_2} 字节(上限 {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} 个对象(上限 {MAX_OBJECTS}) +@msg {at}: {msg} {at}:{msg} +@msg {what} points outside memory {what} 指向内存之外 +@msg `angle` must be a number `angle` 必须是数字 +@msg `aspect` must be from 0.01 to 100 `aspect` 必须在 0.01 到 100 之间 +@msg `cap` must be "butt", "round" or "square" `cap` 必须是 "butt"、"round" 或 "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` 必须是 "nonzero" 或 "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` 必须是不超过 {MAX_HELP_URL} 字节的 https:// 地址 +@msg `help` must be 1-{MAX_HELP} printable characters `help` 必须是 1-{MAX_HELP} 个可打印字符 +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` 必须是 1-{MAX_EFFECT_ID} 字节的效果 ID +@msg `id` must be the id of an input object `id` 必须是某个输入对象的 ID +@msg `join` must be "miter", "round" or "bevel" `join` 必须是 "miter"、"round" 或 "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` 和 `group` 必须是 1-{MAX_LABEL} 个可打印字符 +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` 必须是 1-{MAX_TEXT},且默认值不能更长 +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` 和 `maxItems` 必须是 0-{MAX_LIST},`maxItems` 至少为 1,且默认值的项数在此范围内 +@msg `name` must be a string `name` 必须是字符串 +@msg `opacity` must be a number `opacity` 必须是数字 +@msg `type` must be "path" or "compound" `type` 必须是 "path" 或 "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` 必须是 1-{MAX_UNIT} 个可打印字符 +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` 必须为 `{other}` 列出 1 到 {MAX_CONDITION_VALUES} 个值 +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` 指定了 `{other}`,但它不是在此参数之前声明的参数 +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` 最多指定 {MAX_CONDITIONS} 个参数 +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` 接受 `{other}` 的值,该参数须为复选框或选项 +@msg `visible` must be true or false `visible` 必须是 true 或 false +@msg `{key}` must be a number from {_1} to {_2} `{key}` 必须是 {_1} 到 {_2} 之间的数字 +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` 必须是最多 {MAX_ITEMS} 项的数组 +@msg a color default must be a color object or a hex string such as "#3a7bd5" 颜色默认值必须是颜色对象或 "#3a7bd5" 这样的十六进制字符串 +@msg a color object or a hex string such as "#3a7bd5" 颜色对象或 "#3a7bd5" 这样的十六进制字符串 +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines 任意形状渐变最多包含 {MAX_ITEMS} 个点和线 +@msg a freeform gradient line names a point it doesn't have 任意形状渐变的一条线指定了渐变中没有的点 +@msg a gradient needs 1-{MAX_ITEMS} stops 渐变需要 1-{MAX_ITEMS} 个色标 +@msg a list of numbers 数字列表 +@msg a list of {min_items} to {max_items} numbers 包含 {min_items} 到 {max_items} 个数字的列表 +@msg a new object needs a `path` 新对象需要 `path` +@msg a number 数字 +@msg a painted live effect returns only `objects` 绘制型实时效果只返回 `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` 绘制型实时效果的片段只有 `path`、`fillRule`、`fills` 和 `strokes` +@msg a read-only object was left out 漏掉了一个只读对象 +@msg a read-only object was left out: return it, as {"id"} alone or as it was given 漏掉了一个只读对象:请以单独的 {"id"} 或原样返回它 +@msg a string 字符串 +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 描边只有 `paint`、`width`、`join`、`cap` 和 `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 描边必须是 {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 形式 +@msg an effect must be {"id", "params"?, "visible"?} 效果必须是 {"id", "params"?, "visible"?} 形式 +@msg an effect on a fill returns only `fills` 填色上的效果只返回 `fills` +@msg an effect on a stroke returns only `strokes` 描边上的效果只返回 `strokes` +@msg an object must be a JSON object 对象必须是 JSON 对象 +@msg bad range 范围无效 +@msg choices must be 1-64 non-empty, printable strings 选项必须是 1-64 个非空的可打印字符串 +@msg coordinate out of range (limit ±{MAX_COORD} pt) 坐标超出范围(上限 ±{MAX_COORD} pt) +@msg default is not one of the options 默认值不是选项之一 +@msg each piece needs a `path` 每个片段都需要 `path` +@msg is listed twice 列出了两次 +@msg pattern transform out of range 图案变换超出范围 +@msg read-only field '{field}' changed 只读字段 '{field}' 已更改 +@msg stops must be in order of `offset` 色标必须按 `offset` 排序 +@msg the document has no pattern {pattern} 文档中没有图案 {pattern} +@msg the document has no swatch {n} 文档中没有色板 {n} +@msg the output 输出 +@msg the path has no anchor points 路径没有锚点 +@msg too many anchor points (limit {MAX_ANCHORS}) 锚点过多(上限 {MAX_ANCHORS}) +@msg true or false true 或 false +@msg unknown key `{k}`; the keys here are {_1} 未知键 `{k}`;此处可用的键:{_1} +@msg {_1} is listed twice {_1} 列出了两次 +@msg {_1} is not the id of an input object {_1} 不是输入对象的 ID +@msg {_1} is read-only {_1} 为只读 +@msg {what} must be from {lo} to {hi} {what} 必须在 {lo} 到 {hi} 之间 +@msg a color component 颜色分量 +@msg a plate 印版 # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? 要安装需要这些权限的“{name}”吗? ID: {id} ID:{id} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1c753576a5..bad5bc8e16 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5737,6 +5737,110 @@ axis Y Axis Y 軸 @msg a live effect runs while the document is drawn, so it can't be `heavy` 即時效果會在繪製文件時執行,因此不能是 `heavy` @msg an exporter always gets an export's budget: leave `budget` out 匯出增效模組一律使用匯出的預算:請移除 `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants 「{_1}」需要大量運算預算(每次執行最多 {minutes} 分鐘,指令數為一般的 {times} 倍):需要有人在桌面應用程式中核准,或在以 --allow-plugin-grants 啟動的工作階段中傳入 `allowHeavy: true` +# Plug-in host messages: manifests, modules, runs and plug-in output +@msg `name` must be 1-64 printable characters `name` 必須是 1-64 個可列印字元 +@msg `version`, `description` or `author` is too long `version`、`description` 或 `author` 太長 +@msg `warnings` must be a list of strings `warnings` 必須是字串清單 +@msg `{k}` must be one of {_1} `{k}` 必須是 {_1} 其中之一 +@msg `{k}` must be {what} `{k}` 必須是 {what} +@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 匯出增效模組宣告 1 到 {MAX_EXTENSIONS} 個 `extensions` +@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` 群組外最多 {MAX_UNGROUPED} 個參數:請為其餘參數指定 `group` +@msg can't encode the input 無法編碼輸入 +@msg expected {"dataBase64"} 應為 {"dataBase64"} +@msg expected {"objects"} 應為 {"objects"} +@msg export `{name}`: {e} 模組匯出 `{name}`:{e} +@msg extension {e} is listed twice 副檔名 {e} 列出了兩次 +@msg extension {e}: use 1-16 lower-case letters and digits, without the dot 副檔名 {e}:請使用 1-16 個小寫字母和數字,不含點 +@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - 無效的 ID {_1}:請使用 1-64 個 A-Z a-z 0-9 . _ - 中的字元 +@msg invalid parameter name {k} 無效的參數名稱 {k} +@msg manifest is not UTF-8 資訊清單不是 UTF-8 編碼 +@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) 資訊清單太長(上限 {MAX_MANIFEST_BYTES} 位元組) +@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) 資訊清單為 {_1} 位元組(上限 {MAX_MANIFEST_BYTES}) +@msg module is {_1} bytes (limit {_2}) 模組為 {_1} 位元組(上限 {_2}) +@msg needs approval: it asks for {0}, which the user grants when installing it on its own 需要核准:它要求 {0},使用者單獨安裝它時授予這些權限 +@msg not JSON: {e} 不是 JSON:{e} +@msg not a WebAssembly binary (missing \0asm header) 不是 WebAssembly 二進位檔(沒有 \0asm 標頭) +@msg parameter `{k}`: {what} 參數 `{k}`:{what} +@msg parameter `{name}`: {what} 參數 `{name}`:{what} +@msg plug-in parameters must be a JSON object 增效模組參數必須是 JSON 物件 +@msg the module must export its linear memory as `memory` 模組必須將其線性記憶體匯出為 `memory` +@msg the objects to change are no longer in the document 要變更的物件已不在文件中 +@msg the runtime panicked 執行階段當機 +@msg unexpected host trap 主機發生非預期的 trap +@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version 傳回了 {v};此主機實作的是版本 {ABI_VERSION} +@msg vc_alloc returned 0 (out of memory) vc_alloc 傳回了 0(記憶體不足) +@msg vc_alloc returned a block outside memory vc_alloc 傳回了記憶體之外的區塊 +@msg vc_run returned error code {packed} vc_run 傳回了錯誤代碼 {packed} +@msg {_1} is not a file {_1} 不是檔案 +@msg {_1} is {_2} bytes (limit {_3}) {_1} 為 {_2} 位元組(上限 {_3}) +@msg {_1} objects (limit {MAX_OBJECTS}) {_1} 個物件(上限 {MAX_OBJECTS}) +@msg {at}: {msg} {at}:{msg} +@msg {what} points outside memory {what} 指向記憶體之外 +@msg `angle` must be a number `angle` 必須是數字 +@msg `aspect` must be from 0.01 to 100 `aspect` 必須介於 0.01 到 100 之間 +@msg `cap` must be "butt", "round" or "square" `cap` 必須是 "butt"、"round" 或 "square" +@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` 必須是 "nonzero" 或 "evenodd" +@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` 必須是不超過 {MAX_HELP_URL} 位元組的 https:// 位址 +@msg `help` must be 1-{MAX_HELP} printable characters `help` 必須是 1-{MAX_HELP} 個可列印字元 +@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` 必須是 1-{MAX_EFFECT_ID} 位元組的效果 ID +@msg `id` must be the id of an input object `id` 必須是某個輸入物件的 ID +@msg `join` must be "miter", "round" or "bevel" `join` 必須是 "miter"、"round" 或 "bevel" +@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` 和 `group` 必須是 1-{MAX_LABEL} 個可列印字元 +@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` 必須是 1-{MAX_TEXT},且預設值不能更長 +@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` 和 `maxItems` 必須是 0-{MAX_LIST},`maxItems` 至少為 1,且預設值的項目數須在此範圍內 +@msg `name` must be a string `name` 必須是字串 +@msg `opacity` must be a number `opacity` 必須是數字 +@msg `type` must be "path" or "compound" `type` 必須是 "path" 或 "compound" +@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` 必須是 1-{MAX_UNIT} 個可列印字元 +@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` 必須為 `{other}` 列出 1 到 {MAX_CONDITION_VALUES} 個值 +@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` 指定了 `{other}`,但它不是在此參數之前宣告的參數 +@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` 最多指定 {MAX_CONDITIONS} 個參數 +@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` 接受 `{other}` 的值,該參數須為核取方塊或選項 +@msg `visible` must be true or false `visible` 必須是 true 或 false +@msg `{key}` must be a number from {_1} to {_2} `{key}` 必須是介於 {_1} 到 {_2} 之間的數字 +@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` 必須是最多 {MAX_ITEMS} 項的陣列 +@msg a color default must be a color object or a hex string such as "#3a7bd5" 色彩預設值必須是色彩物件或 "#3a7bd5" 這類十六進位字串 +@msg a color object or a hex string such as "#3a7bd5" 色彩物件或 "#3a7bd5" 這類十六進位字串 +@msg a freeform gradient holds at most {MAX_ITEMS} points and lines 任意形狀漸層最多包含 {MAX_ITEMS} 個點和線 +@msg a freeform gradient line names a point it doesn't have 任意形狀漸層的一條線指定了漸層中沒有的點 +@msg a gradient needs 1-{MAX_ITEMS} stops 漸層需要 1-{MAX_ITEMS} 個色標 +@msg a list of numbers 數字清單 +@msg a list of {min_items} to {max_items} numbers 包含 {min_items} 到 {max_items} 個數字的清單 +@msg a new object needs a `path` 新物件需要 `path` +@msg a number 數字 +@msg a painted live effect returns only `objects` 繪製型即時效果只傳回 `objects` +@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` 繪製型即時效果的片段只有 `path`、`fillRule`、`fills` 和 `strokes` +@msg a read-only object was left out 漏掉了一個唯讀物件 +@msg a read-only object was left out: return it, as {"id"} alone or as it was given 漏掉了一個唯讀物件:請以單獨的 {"id"} 或原樣傳回它 +@msg a string 字串 +@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 筆畫只有 `paint`、`width`、`join`、`cap` 和 `miterLimit` +@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 筆畫必須是 {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 形式 +@msg an effect must be {"id", "params"?, "visible"?} 效果必須是 {"id", "params"?, "visible"?} 形式 +@msg an effect on a fill returns only `fills` 填色上的效果只傳回 `fills` +@msg an effect on a stroke returns only `strokes` 筆畫上的效果只傳回 `strokes` +@msg an object must be a JSON object 物件必須是 JSON 物件 +@msg bad range 範圍無效 +@msg choices must be 1-64 non-empty, printable strings 選項必須是 1-64 個非空白的可列印字串 +@msg coordinate out of range (limit ±{MAX_COORD} pt) 座標超出範圍(上限 ±{MAX_COORD} pt) +@msg default is not one of the options 預設值不是選項之一 +@msg each piece needs a `path` 每個片段都需要 `path` +@msg is listed twice 列出了兩次 +@msg pattern transform out of range 圖樣變形超出範圍 +@msg read-only field '{field}' changed 唯讀欄位 '{field}' 已變更 +@msg stops must be in order of `offset` 色標必須依 `offset` 排序 +@msg the document has no pattern {pattern} 文件中沒有圖樣 {pattern} +@msg the document has no swatch {n} 文件中沒有色票 {n} +@msg the output 輸出 +@msg the path has no anchor points 路徑沒有錨點 +@msg too many anchor points (limit {MAX_ANCHORS}) 錨點過多(上限 {MAX_ANCHORS}) +@msg true or false true 或 false +@msg unknown key `{k}`; the keys here are {_1} 未知的鍵 `{k}`;此處可用的鍵:{_1} +@msg {_1} is listed twice {_1} 列出了兩次 +@msg {_1} is not the id of an input object {_1} 不是輸入物件的 ID +@msg {_1} is read-only {_1} 為唯讀 +@msg {what} must be from {lo} to {hi} {what} 必須介於 {lo} 到 {hi} 之間 +@msg a color component 色彩分量 +@msg a plate 色版 # Plug-in permissions (Install Plug-in…, plugin.install grant) Install “{name}”, which asks for these permissions? 要安裝需要這些權限的「{name}」嗎? ID: {id} ID:{id} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f28078ec33..2f76dae1df 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -814,6 +814,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). - The status bar translates the limits a plug-in run can exceed (its instruction, time and memory budgets and its output size) and the error of a plug-in removal that leaves the plug-in's grant in the plug-in store, in every language that translates every message. The untranslated-strings report also lists messages that an error or a status takes from a `&str` constant (`plugin_limit_errors_are_translated`, `message_scan_reads_messages_held_in_constants`). + - The status bar translates the plug-in host's other errors (manifest, module and run checks, the output an importer, exporter or effect returns, and the approval a plug-in's permissions need) in every language that translates every message. A JSON object or array inside a message stays as written, and a message that wraps another one, such as `plug-in manifest: {0}`, is matched by its own row before a row that starts with a placeholder. The reasons inside these errors (what is wrong with a parameter's spec or dialog keys, the value a parameter needs, what is wrong with an object, stroke, paint or effect in a plug-in's output) are translated with them, and the untranslated-strings report lists the reasons the plug-in host's error helpers take (`plugin_messages_are_translated_in_every_complete_catalog`, `plugin_errors_translate_with_their_values`, `plugin_error_reasons_are_translated_with_the_error`, `message_scan_reads_plugin_helpers`, `templates_that_start_with_text_match_first`). - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian, Georgian and Catalan ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). The counts that Package's status and question, the folder search of Missing Fonts and Find Linked Files, the modified and missing linked file prompts and the CSS Properties panel's notes show use the interface language's plural forms instead of a fixed "(s)" form. Evidence: `the_missing_links_note_uses_each_count_form` in `crates/ui-egui/src/dialogs/tests_package.rs`, `the_modified_links_question_uses_each_count_form` in `crates/ui-egui/src/dialogs/missing_links.rs`, `search_counts_use_each_count_form` in `crates/ui-egui/src/dialogs/missing_fonts/tests.rs` and `the_unnamed_objects_note_uses_each_count_form` in `crates/ui-egui/src/panels/tests_css_properties.rs`. The status bar's messages for relinked, replaced, updated and embedded images, broken links, linked files that can't be read or changed on disk, images that can't be decoded, added and unavailable fonts, copied CSS, downloads and exports with their notes name one item in the singular and more in the plural, in English and in each language's plural forms (`counted_status_messages_take_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`). From 97eecdb21e49dfd789000000d887b53bdc3fe2a8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:37:07 -0800 Subject: [PATCH 09/34] Stroke: a group used as a clipping path adds nothing to a custom arrowhead A custom arrowhead takes a clipping path's own fills and strokes only where the clipping path paints them on the canvas. A group used as the clipping path paints nothing, so its members' fills no longer widen the head past the clipped art. --- crates/engine/src/cmd/arrowheads.rs | 15 ++++++++++++--- crates/engine/src/tests/arrowheads.rs | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/arrowheads.rs b/crates/engine/src/cmd/arrowheads.rs index 4dc58f6c8e..0aeda59731 100644 --- a/crates/engine/src/cmd/arrowheads.rs +++ b/crates/engine/src/cmd/arrowheads.rs @@ -222,7 +222,8 @@ impl Shapes { /// their strokes' outlines, its groups' members included (baking has already made a group's own /// fills and strokes art among its members), and the areas its gradient meshes cover. A clip /// group's clipped art is clipped to -/// its clipping path, whose own fill and stroke paint unclipped, as on the canvas. Hidden or fully +/// its clipping path, whose own fill and stroke paint unclipped, as on the canvas; a group used as +/// the clipping path paints nothing ([`clip_paint`]). Hidden or fully /// transparent art is left out. fn painted(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node, out: &mut Shapes) { if !n.visible || n.opacity <= 0.0 || out.too_detailed { @@ -239,7 +240,7 @@ fn painted(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node, out } NodeKind::Group { children, clip: true } => { let Some((clip, rest)) = children.split_first() else { return }; - painted(d, brushes, clip, out); + clip_paint(clip).iter().for_each(|part| painted(d, brushes, part, out)); // Nothing to clip by hides the clipped art, as on the canvas. let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return }; let mut inside = Shapes { anchors: out.anchors, ..Default::default() }; @@ -253,6 +254,14 @@ fn painted(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node, out } } +/// What clipping path `clip` paints ([`Node::clip_paint`]): its fills and its strokes, each as a +/// copy of it keeping only those. A group or another object that does not paint as a clipping +/// path gives neither. +fn clip_paint(clip: &Node) -> Vec { + let paint = clip.clip_paint(); + [paint.fill, paint.stroke].into_iter().flatten().collect() +} + /// The areas the shown paths and gradient meshes in `n` (an outlined stroke) fill, clipped by its /// clip groups, whose clipping paths' own fills paint unclipped. A stroke with a gradient along or /// across it is outlined as gradient meshes clipped to the stroke's outline. @@ -264,7 +273,7 @@ fn fills_in(n: &Node, out: &mut Shapes) { NodeKind::Mesh(_) => mesh_area(n, out), NodeKind::Group { children, clip: true } => { let Some((clip, rest)) = children.split_first() else { return }; - fills_in(clip, out); + clip_paint(clip).iter().for_each(|part| fills_in(part, out)); let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return }; let mut inside = Shapes { anchors: out.anchors, ..Default::default() }; rest.iter().for_each(|c| fills_in(c, &mut inside)); diff --git a/crates/engine/src/tests/arrowheads.rs b/crates/engine/src/tests/arrowheads.rs index 6b31fbcfda..b350341767 100644 --- a/crates/engine/src/tests/arrowheads.rs +++ b/crates/engine/src/tests/arrowheads.rs @@ -348,3 +348,18 @@ fn a_symbol_whose_shapes_cannot_be_combined_is_skipped() { let skipped: Vec<&str> = read.skipped.iter().map(|(n, _)| n.as_str()).collect(); assert_eq!(skipped, ["Broken", "Broken Clip"], "{:?}", read.skipped); } + +#[test] +fn a_group_used_as_the_clipping_path_adds_none_of_its_own_fills() { + // A group holding a filled 20 × 4 bar clips a filled 4 × 4 square: the group paints nothing as + // the clipping path, so the head is the square alone. + let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled()); + let mask = Node::group(NodeId(0), vec![Arc::new(bar)]); + let square = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], filled()); + let mut d = Document::new(100.0, 100.0); + add_symbol(&mut d, "Group Clip", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(square)], clip: true })); + let read = heads_of(&d); + assert_eq!(read.heads.len(), 1, "{:?}", read.skipped); + let b = read.heads[0].path.bounds().unwrap(); + assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the square alone: {b:?}"); +} From 072c0b79b99bb6e83b39101ae4ab1078ba8c7d4d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:06:11 -0800 Subject: [PATCH 10/34] Stroke: an arrowheads file whose data goes on past the size limit is not read A compressed arrowheads file is refused when the data after its document, the preview included, unpacks to more than 64 MB, so the whole stream is read and its checksum checked. Other files are read as before. --- crates/format/src/lib.rs | 4 ++-- crates/format/src/unpack.rs | 33 +++++++++++++++++++++------------ 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index f3efb3784b..52439ca3a6 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -504,8 +504,8 @@ pub fn load_file(bytes: &[u8]) -> Result { load_file_within(bytes, vectorcraft_doc::node::MAX_READ_NODES, vectorcraft_doc::node::MAX_BUFFERED_VALUES) } -/// [`load_file`] with a compressed file refused when its JSON, or the data after it that the -/// document uses (images, profiles), unpacks to more than `max` bytes (at most [`MAX_JSON`] and +/// [`load_file`] with a compressed file refused when its JSON, or the data after it (images, +/// profiles, the preview), unpacks to more than `max` bytes (at most [`MAX_JSON`] and /// [`MAX_BLOBS`]), or, when it lacks the marker after its JSON, when all of it does. pub fn load_file_capped(bytes: &[u8], max: u64) -> Result { let file = unpack::Unpacked::within_capped(bytes, max.min(MAX_JSON), max.min(MAX_UNPACKED), max.min(MAX_BLOBS))?; diff --git a/crates/format/src/unpack.rs b/crates/format/src/unpack.rs index fed726bfab..ec9623118c 100644 --- a/crates/format/src/unpack.rs +++ b/crates/format/src/unpack.rs @@ -32,6 +32,9 @@ pub(crate) struct Unpacked<'a> { max_blobs: u64, /// Most bytes unpacked to find them in a streamed file ([`MAX_WORK`] unless capped). max_work: u64, + /// Must a streamed file's data after the JSON end within `max_blobs` bytes + /// ([`Self::within_capped`])? + capped: bool, } enum Json<'a> { @@ -72,16 +75,18 @@ impl<'a> Unpacked<'a> { /// a document several times its size, and the cap is what keeps that off the address space /// (#3587). fn within(bytes: &'a [u8], max_json: u64) -> Result { - Self::within_capped(bytes, max_json, MAX_UNPACKED, MAX_BLOBS) + let (json, tail) = Self::open(bytes, max_json, MAX_UNPACKED)?; + Ok(Self { json, tail, max_blobs: MAX_BLOBS, max_work: max_json.saturating_add(MAX_BLOBS).min(MAX_WORK), capped: false }) } /// [`Self::within`], with a compressed file that lacks the marker after its JSON (unpacked /// whole) capped at `max_whole` bytes instead of [`MAX_UNPACKED`], the entries after the JSON /// at `max_blobs` bytes together instead of [`MAX_BLOBS`], and the bytes unpacked to find them - /// at `max_json` and `max_blobs` together instead of [`MAX_WORK`]. + /// at `max_json` and `max_blobs` together instead of [`MAX_WORK`]. A streamed file whose data + /// after the JSON unpacks to more than `max_blobs` bytes is refused when its entries are read. pub(crate) fn within_capped(bytes: &'a [u8], max_json: u64, max_whole: u64, max_blobs: u64) -> Result { let (json, tail) = Self::open(bytes, max_json, max_whole)?; - Ok(Self { json, tail, max_blobs, max_work: max_json.saturating_add(max_blobs).min(MAX_WORK) }) + Ok(Self { json, tail, max_blobs, max_work: max_json.saturating_add(max_blobs).min(MAX_WORK), capped: true }) } /// The JSON of `bytes` and where the data after it is, within `max_json` and `max_whole`. @@ -160,7 +165,7 @@ impl<'a> Unpacked<'a> { /// [`Self::entries`], unpacking at most `work` bytes of a streamed file to find them. fn entries_within(self, wanted: &[[u64; 2]], work: u64) -> Result, FormatError> { - let max_blobs = self.max_blobs; + let (max_blobs, capped) = (self.max_blobs, self.capped); let mut out: Vec = vec![None; wanted.len()]; // Data in memory is read through the crate's `Entries`: entries naming the same bytes // share them, and the bytes copied stay within the data. @@ -214,9 +219,10 @@ impl<'a> Unpacked<'a> { } done.insert(w, slot.clone()); } - // To the end, so the stream's checksum is verified as before. - s.left = max_blobs; - s.skip(max_blobs)?; + // To the end, so the stream's checksum is verified as before. A capped file's data + // must end within its cap from its start: one byte more is over the budget. + s.left = if capped { max_blobs.checked_sub(s.pos).ok_or(FormatError::TooBig)? } else { max_blobs }; + s.skip(if capped { s.left.saturating_add(1) } else { max_blobs })?; } } Ok(out) @@ -339,11 +345,14 @@ mod tests { assert_eq!(read(&[[1 << 40, 1]], 1 << 20).unwrap(), vec![None]); assert_eq!(Unpacked::new(&file).unwrap().entries(&[[0, 4096]]).unwrap(), vec![Some(Arc::new(tail.clone()))]); // A capped file finds its entries within its own caps: a small entry far into the data, - // or entries larger than the cap together, are refused. - let capped = || Unpacked::within_capped(&file, 64, MAX_UNPACKED, 16).unwrap(); - assert_eq!(capped().entries(&[[0, 16]]).unwrap(), vec![Some(Arc::new(tail[..16].to_vec()))]); - assert!(matches!(capped().entries(&[[4000, 1]]), Err(FormatError::TooBig))); - assert!(matches!(capped().entries(&[[0, 17]]), Err(FormatError::TooBig))); + // entries larger than the cap together, or data that goes on past the cap after the + // entries, are refused. + let capped = |cap: u64| Unpacked::within_capped(&file, 64, MAX_UNPACKED, cap).unwrap(); + assert_eq!(capped(4096).entries(&[[0, 16]]).unwrap(), vec![Some(Arc::new(tail[..16].to_vec()))]); + assert!(matches!(capped(4095).entries(&[[0, 16]]), Err(FormatError::TooBig))); + assert!(matches!(capped(4095).entries(&[[4095, 1]]), Err(FormatError::TooBig))); + assert!(matches!(capped(16).entries(&[[4000, 1]]), Err(FormatError::TooBig))); + assert!(matches!(capped(16).entries(&[[0, 17]]), Err(FormatError::TooBig))); assert_eq!(Unpacked::new(&file).unwrap().entries(&[[4000, 1]]).unwrap(), vec![Some(Arc::new(vec![tail[4000]]))]); } From ccc962300c7482b81c9b9468be91158aa714f7de Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:24:48 -0800 Subject: [PATCH 11/34] =?UTF-8?q?Effects:=20Object=20=E2=80=BA=20Expand,?= =?UTF-8?q?=20Outline=20Stroke=20and=203D=20keep=20the=20raster=20effects?= =?UTF-8?q?=20a=20Transform=20effect=20moves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A raster effect listed before a Transform effect follows that Transform effect on the canvas. Several commands apply and remove the geometry effects, and they dropped the Transform effect while the raster effect kept its old values: Object › Expand of a gradient or pattern fill, Outline Stroke (also Expand's Stroke option and Flatten Transparency's Convert All Strokes to Outlines) and the 3D effects. For a square with a Drop Shadow and a 200% Transform effect, the canvas drew a 20 pt offset and these commands gave a 10 pt one. - These commands now keep the raster effects at the values the canvas uses (without_geometry). - A fill's or stroke's own Transform effect with Transform Patterns or Scale Strokes & Effects on now transforms the raster effects listed before it, as one without these options does. Before, Scale Strokes & Effects only scaled them, and Transform Patterns left them as they were. - In a fill's or stroke's own effects, a Transform effect that makes copies or moves at random keeps the raster effects before it from following the Transform effects after it, as in an object's effects. - The params text and docs/target-app-parity.md say that pixel filters keep their angles and light directions. --- crates/effects/src/catalog.rs | 2 +- crates/effects/src/lib.rs | 2 +- crates/effects/src/raster.rs | 71 +++++++++----- crates/effects/src/reshape.rs | 68 ++++++++++--- crates/effects/src/revolve.rs | 19 ++-- crates/effects/src/solids.rs | 22 ++--- crates/effects/src/tests/rasterfollow.rs | 116 +++++++++++++++++++++++ crates/engine/src/cmd/expand.rs | 13 ++- crates/engine/src/cmd/pathops.rs | 8 +- crates/engine/src/tests/objexpand.rs | 21 ++++ crates/engine/src/tests/outlinestroke.rs | 24 +++++ docs/target-app-parity.md | 2 +- 12 files changed, 298 insertions(+), 70 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 56d9cf601e..8edea9d535 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -265,7 +265,7 @@ pub fn effect_catalog() -> Vec { "distort.transform", "Transform…", DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, while the angles and light directions of pixel filters stay as they are), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), ), g( diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 92ee086147..b211a6f94a 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -78,7 +78,7 @@ pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_par pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner}; pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; -pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects}; +pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects, without_geometry}; pub use reshape::{ SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index d79aac09cc..07140e2bc9 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::color::{BlendMode, Color}; use vectorcraft_doc::rastersettings::MAX_ADD_AROUND; use vectorcraft_doc::{Effect, RasterColorModel, RasterEffectsSettings}; -use vectorcraft_geom::{Affine, Point, Rect}; +use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use crate::merged_params; use crate::pixel::{self, PixelFx, PixelSpace}; @@ -437,7 +437,7 @@ pub fn raster_effects(effects: &[Effect]) -> Vec { /// random follows stays as it is, as does one after the last Transform effect. Borrowed when /// nothing changes. pub(crate) fn followed(effects: &[Effect]) -> Cow<'_, [Effect]> { - if !effects.iter().any(|e| e.visible && e.id == TRANSFORM) { + if !effects.iter().any(|e| e.visible && e.id == "distort.transform") { return Cow::Borrowed(effects); } let mut out: Option> = None; @@ -445,16 +445,8 @@ pub(crate) fn followed(effects: &[Effect]) -> Cow<'_, [Effect]> { // `None` once one of them makes copies or moves at random. let mut after = Some(Affine::IDENTITY); for (i, e) in effects.iter().enumerate().rev() { - if e.id == TRANSFORM { - if !e.visible { - continue; - } - let p = merged_params(&e.id, &e.params); - // With Transform Objects off the art stays where it is. - if flag(&p, "objects", true) { - let (map, copies) = crate::distort::transform_map(Rect::ZERO, &p, 0); - after = after.filter(|_| copies == 0 && !flag(&p, "random", false)).map(|a| a * linear(map)); - } + if e.id == "distort.transform" { + after = after.and_then(|a| transform_step(e).map(|s| a * s)); continue; } let Some(a) = after.filter(|a| *a != Affine::IDENTITY && follows(&e.id)) else { continue }; @@ -467,13 +459,54 @@ pub(crate) fn followed(effects: &[Effect]) -> Cow<'_, [Effect]> { } /// `effects` without their geometry effects, each raster effect following the Transform effects -/// it loses ([`followed`]): what an object keeps once its geometry effects are applied to its art. -pub(crate) fn without_geometry(effects: &[Effect]) -> Vec { +/// it loses ([`followed`]): what an object, or a fill or stroke, keeps once its geometry effects +/// are applied to its art. +pub fn without_geometry(effects: &[Effect]) -> Vec { followed(effects).iter().filter(|e| !crate::is_geometry(&e.id)).cloned().collect() } -/// The Transform effect's id. -const TRANSFORM: &str = "distort.transform"; +/// Make the raster effects of `effects`, all listed before Transform effect `t`, follow `t` as +/// [`followed`] makes them follow it, for art that applies its effects one at a time. `false` +/// when `t` leaves them as they are. +pub(crate) fn follow_one(effects: &mut [Effect], t: &Effect) -> bool { + let Some(a) = transform_step(t).filter(|a| *a != Affine::IDENTITY) else { return false }; + let mut changed = false; + for e in effects.iter_mut().filter(|e| follows(&e.id)) { + follow_transform(e, a); + changed = true; + } + changed +} + +/// How many of the effects of `effects` that [`without_geometry`] keeps no longer follow the +/// Transform effects listed after `effects` ([`followed`]): those before the last visible Transform +/// effect among them that makes copies or moves at random. +pub(crate) fn held_back(effects: &[Effect]) -> usize { + let Some(last) = effects.iter().rposition(|e| transform_step(e).is_none()) else { return 0 }; + effects.get(..last).unwrap_or_default().iter().filter(|e| !crate::is_geometry(&e.id)).count() +} + +/// Does `e` stop the raster effects listed before it from following the Transform effects after +/// it ([`followed`]): is it a visible Transform effect that makes copies or moves at random? +pub(crate) fn holds_back(e: &Effect) -> bool { + transform_step(e).is_none() +} + +/// What `e` does to the raster effects listed before it ([`followed`]): the linear part of its map +/// when it is a visible Transform effect that moves the art once, the identity when it is no +/// Transform effect, is hidden or has Transform Objects off (the art stays where it is), and `None` +/// when it makes copies or moves at random. +fn transform_step(e: &Effect) -> Option { + if e.id != "distort.transform" || !e.visible { + return Some(Affine::IDENTITY); + } + let p = merged_params(&e.id, &e.params); + if !flag(&p, "objects", true) { + return Some(Affine::IDENTITY); + } + let (map, copies) = crate::distort::transform_map(Rect::ZERO, &p, 0); + (copies == 0 && !flag(&p, "random", false)).then(|| map.with_translation(Vec2::ZERO)) +} /// Does raster effect `id` follow the Transform effects listed after it ([`followed`])? An SVG /// filter does not, and neither does Rasterize, whose image keeps its own pixel grid and room @@ -482,12 +515,6 @@ fn follows(id: &str) -> bool { crate::is_raster(id) && id != crate::SVG_FILTER && id != RASTERIZE } -/// `a` without its translation. -fn linear(a: Affine) -> Affine { - let [a0, a1, a2, a3, _, _] = a.as_coeffs(); - Affine::new([a0, a1, a2, a3, 0.0, 0.0]) -} - /// Make raster effect `e` follow `a`, the linear part of a Transform effect listed after it, as /// that effect transforms what `e` paints: its distances and raster sizes scale by `a`'s mean /// scale ([`crate::scale_effect`]), and a Drop Shadow's offset turns, reflects and scales with diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 36e34a90b4..b25c016cfd 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -1263,9 +1263,12 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt let params = merged_params(&fx[at].id, &fx[at].params); // Where each effect the piece keeps sits among the item's effects. let kept: Vec = (0..at).filter(|&k| !is_geometry(&fx[k].id)).chain(at + 1..fx.len()).collect(); + // The effects before the Transform effect that it keeps, each raster effect following the + // geometry effects before it ([`crate::raster::without_geometry`]). + let kept_before = crate::without_geometry(before); if let Some(plugin) = painted_plugin(&fx[at]) { let mut it = item.clone(); - let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); + let rest: Vec = kept_before.iter().chain(&fx[at + 1..]).cloned().collect(); *it.effects_mut() = rest; let mut one = Appearance { items: vec![it.clone()], ..Default::default() }; if carry.moves(&one) { @@ -1287,7 +1290,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt group.push(Arc::new(p)); } let mut p = Node::group(leaf.id, group); - transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc); + transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(before), limit, used, doc); pieces.push(Arc::new(p)); continue; } @@ -1295,16 +1298,20 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt let options = crate::distort::TransformOptions::of(¶ms); let map = crate::distort::transform_affine(tb, ¶ms, ctx.seed); let mut it = item.clone(); - let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); + let rest: Vec = kept_before.iter().chain(&fx[at + 1..]).cloned().collect(); *it.effects_mut() = rest; let mut p = piece(vec![it]); if carry.moves(&p.appearance) { carry.apply(&mut p.appearance); } set_geometry(&mut p, g.clone(), rule); - let (made, _) = transform_leaf(&mut p, &g, map, copies, options, room); + let (made, moved) = transform_leaf(&mut p, &g, map, copies, options, room); *used = used.plus(made); - transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc); + let from = crate::raster::held_back(before); + if moved { + follow_piece(&mut p, kept_before.get(from..).unwrap_or_default(), from, &fx[at]); + } + transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(&fx[..=at]), limit, used, doc); pieces.push(Arc::new(p)); } if !plain.is_empty() { @@ -1324,24 +1331,32 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt /// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with /// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does. /// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path -/// for each piece it returns ([`paint_piece`]). The geometry effects then leave the fills and -/// strokes; their other effects stay. The paths keep to `max` together with the rest of the art -/// (`used`), and the swatches and patterns painted pieces name must be in `doc`. `(seed, source, -/// kept)`: the object's seed, the index in the object's appearance of the fill or stroke the -/// piece was made from, and the index among that item's effects of each effect the piece kept, -/// which a deferred live-effect plug-in is listed by. -fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), max: Budget, used: &mut Budget, doc: Option<&Document>) { +/// for each piece it returns ([`paint_piece`]). The raster effects before a Transform effect +/// follow it ([`follow_piece`]), except the first `held` effects, which a Transform effect before +/// the piece's effects that makes copies or moves at random holds back. The geometry effects then +/// leave the fills and strokes; their other effects stay. The paths keep to `max` together with +/// the rest of the art (`used`), and the swatches and patterns painted pieces name must be in +/// `doc`. `(seed, source, kept)`: the object's seed, the index in the object's appearance of the +/// fill or stroke the piece was made from, and the index among that item's effects of each effect +/// the piece kept, which a deferred live-effect plug-in is listed by. +fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), held: usize, max: Budget, used: &mut Budget, doc: Option<&Document>) { let mut count = 0; for_each_leaf(p, &mut |l| { if let Some(item) = l.appearance.items.first() { count = count.max(item.effects().len()); } }); + let mut held = held; for j in 0..count { let Some(bounds) = p.geometric_bounds() else { break }; + let from = held.min(j); + let mut holds = false; for_each_leaf(p, &mut |leaf| { let Some(item) = leaf.appearance.items.first().cloned() else { return }; let Some(e) = item.effects().get(j).filter(|e| e.visible && is_geometry(&e.id)).cloned() else { return }; + holds |= crate::raster::holds_back(&e); + // The effects before this one as they were before it moved the path. + let was = item.effects().get(from..j).unwrap_or_default(); let Some((path, rule)) = leaf_geometry(leaf) else { return }; // What the other paths leave of the budget. let before = Budget::of(&path); @@ -1358,8 +1373,11 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m let (_, copies) = crate::distort::transform_map(bounds, ¶ms, seed); let options = crate::distort::TransformOptions::of(¶ms); let map = crate::distort::transform_affine(bounds, ¶ms, seed); - let (made, _) = transform_leaf(leaf, &path, map, copies, options, room); + let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room); *used = used.minus(before).plus(made); + if moved { + follow_piece(leaf, was, from, &e); + } return; } let appearance = leaf.appearance.clone(); @@ -1376,7 +1394,11 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m carry.apply(&mut leaf.appearance); } set_geometry(leaf, out, rule); + follow_piece(leaf, was, from, &e); }); + if holds { + held = j; + } } for_each_leaf(p, &mut |leaf| { for item in &mut leaf.appearance.items { @@ -1385,6 +1407,26 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m }); } +/// Make the raster effects of `leaf`'s first fill or stroke from index `from` on follow Transform +/// effect `t`, listed after them, once `t` has moved the leaf ([`crate::raster::follow_one`]). +/// `was` holds those effects as they were before `t` was applied: each one that follows takes the +/// value [`crate::raster::followed`] gives it, in place of what Scale Strokes & Effects made of it, +/// and the others keep theirs. +fn follow_piece(leaf: &mut Node, was: &[Effect], from: usize, t: &Effect) { + let mut follow = was.to_vec(); + if !crate::raster::follow_one(&mut follow, t) { + return; + } + let Some(item) = leaf.appearance.items.first_mut() else { return }; + for (k, (new, old)) in follow.into_iter().zip(was).enumerate() { + if new != *old + && let Some(e) = item.effects_mut().get_mut(from + k) + { + *e = new; + } + } +} + /// 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>) { diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index e7fd5e5f22..aed6240531 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -8,7 +8,9 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, L use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve}; -use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, resolve_for, solid_paints, union_bounds}; +use crate::solids::{ + Profile, Scope, is_container, members, object_profile, past_effect, profile, reshape_members, resolve_for, solid_paints, union_bounds, +}; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; @@ -558,17 +560,11 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { } children.push(Arc::new(surface)); // Later geometry acts on the projected art as a whole through container evaluation. - remaining = n - .appearance - .effects - .iter() - .enumerate() - .filter(|(i, e)| !is_revolve(&e.id) && (!is_geometry(&e.id) || *i > index)) - .map(|(_, e)| e.clone()) - .collect(); + remaining = past_effect(&n.appearance.effects, index); + remaining.retain(|e| !is_revolve(&e.id)); } else { let path = geometry(&path, &n.appearance.effects, &ctx); - remaining.retain(|e| !is_geometry(&e.id)); + remaining = crate::without_geometry(&remaining); // Every item's shadow goes behind every item's art. let mut shadows = vec![]; for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { @@ -830,8 +826,7 @@ fn group_art(n: &Node, expand: bool) -> Option { // One shadow for every member's surface, behind them all. let mut children = children; children.splice(0..0, group_shadow(n.id, fx, &shapes, b, options).ok()?); - let remaining = - n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); + let remaining = past_effect(&n.appearance.effects, index); let mut art = n.clone(); art.kind = NodeKind::Group { children, clip: false }; art.appearance = Appearance { effects: remaining, ..Default::default() }.into(); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 8d9ff9eea8..4d04a4009a 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -913,17 +913,10 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { let (shadow, surfaces) = art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?; children.extend(shadow.into_iter().chain(surfaces)); // Later geometry effects act on the projected art as a whole. - remaining = n - .appearance - .effects - .iter() - .enumerate() - .filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)) - .map(|(_, e)| e.clone()) - .collect(); + remaining = past_effect(&n.appearance.effects, index); } else { let path = geometry(&path, &n.appearance.effects, &ctx); - remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + remaining = crate::without_geometry(&n.appearance.effects); // Every item's shadow goes behind every item's art. let mut shadows = vec![]; for item in &paints { @@ -959,14 +952,21 @@ fn group_solid_art(n: &Node) -> Option { reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); let (shadow, surfaces) = art_for(n, fx, &shapes)?; let children = shadow.into_iter().chain(surfaces).collect(); - let remaining = - n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); + let remaining = past_effect(&n.appearance.effects, index); let mut art = n.clone(); art.kind = NodeKind::Group { children, clip: false }; art.appearance = Appearance { effects: remaining, ..Default::default() }.into(); Some(art) } +/// `effects` without the 3D effect at `index` and the geometry effects before it, which shape the +/// art it projects. Each raster effect before it follows the Transform effects among those +/// ([`crate::without_geometry`]); the effects after it stay as they are. +pub(crate) fn past_effect(effects: &[Effect], index: usize) -> Vec { + let (before, after) = effects.split_at(index.min(effects.len())); + crate::without_geometry(before).into_iter().chain(after.iter().skip(1).cloned()).collect() +} + /// One solid of a 3D object as a mesh, with the color it is painted. #[derive(Clone, Debug, PartialEq)] pub struct SolidMesh { diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index 8212c11487..5d578d15a8 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -125,6 +125,122 @@ fn rasterize_before_a_transform_keeps_its_options() { assert_eq!(crate::raster::without_geometry(&effects), vec![rasterize]); } +/// An SVG filter keeps its options: it paints in its own user space. +#[test] +fn an_svg_filter_before_a_transform_keeps_its_options() { + let filter = fx(crate::SVG_FILTER, json!({"origin": [10, 10]})); + let effects = [filter.clone(), transform(json!({"scaleH": 200, "scaleV": 200, "rotate": 90}))]; + assert_eq!(crate::raster::followed(&effects)[0], filter); + assert_eq!(crate::raster::without_geometry(&effects), vec![filter]); +} + +/// The offset of every Drop Shadow drawn on `n` and the nodes under it, from the object's effects +/// and from each fill's and stroke's. +fn shadows_in(n: &Node) -> Vec<(f64, f64)> { + let mut out = vec![]; + n.walk(&mut |m| { + let lists = std::iter::once(&m.appearance.effects).chain(m.appearance.items.iter().map(|i| i.effects())); + for list in lists { + for r in raster_effects(list) { + if let RasterFx::DropShadow { dx, dy, .. } = r { + out.push((dx, dy)); + } + } + } + }); + out +} + +fn near(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(p, q)| (p.0 - q.0).abs() < 1e-9 && (p.1 - q.1).abs() < 1e-9) +} + +/// A square whose fill carries `effects` of its own. +fn fill_square(id: u64, effects: Vec) -> Node { + let mut n = square(id, vec![]); + if let Some(item) = n.appearance.items.first_mut() { + *item.effects_mut() = effects; + } + n +} + +/// A fill's own Transform effect transforms the fill's raster effects before it: drawn as they are +/// (no options), drawn through the art the Transform Patterns or Scale Strokes & Effects option +/// makes, expanded and baked. Scale Strokes & Effects does not scale a shadow that follows the +/// Transform effect a second time. +#[test] +fn a_fills_shadow_follows_the_fills_own_transform() { + let cases = [ + (json!({"rotate": 90}), (0.0, -10.0)), + (json!({"rotate": 90, "strokes": true}), (0.0, -10.0)), + (json!({"scaleH": 200, "scaleV": 200, "strokes": true}), (20.0, 0.0)), + (json!({"scaleH": 200, "scaleV": 200, "patterns": true}), (20.0, 0.0)), + (json!({"reflectX": true, "patterns": true}), (-10.0, 0.0)), + ]; + for (params, want) in cases { + let n = fill_square(11, vec![shadow(10.0, 0.0, 0.0), transform(params.clone())]); + // Drawn and expanded through its art when an option makes some. + if crate::has_transform_art(&n) { + let art = crate::reshape(None, &n, None).unwrap(); + assert!(near(&shadows_in(&art), &[want]), "{params}: drawn {:?}", shadows_in(&art)); + let expanded = expand_outlined(None, &n, None).unwrap(); + assert!(near(&shadows_in(&expanded), &[want]), "{params}: expanded {:?}", shadows_in(&expanded)); + } else { + assert!(near(&shadows_in(&n), &[want]), "{params}: drawn {:?}", shadows_in(&n)); + } + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n).unwrap(); + let baked = bake_document(&d).unwrap(); + let b = baked.node(NodeId(11)).unwrap(); + assert!(near(&shadows_in(b), &[want]), "{params}: baked {:?}", shadows_in(b)); + } +} + +/// A Transform effect after a fill's own Transform effect with Scale Strokes & Effects on turns the +/// shadow between them, which the first one scaled; a Transform effect that moves at random holds +/// the shadow before it back from the Transform effects after it. +#[test] +fn a_fills_later_transform_turns_the_shadow_its_options_scaled() { + let n = fill_square( + 12, + vec![transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 0.0, 0.0), transform(json!({"rotate": 90}))], + ); + let art = crate::reshape(None, &n, None).unwrap(); + assert!(near(&shadows_in(&art), &[(0.0, -20.0)]), "{:?}", shadows_in(&art)); + let held = fill_square( + 13, + vec![ + shadow(10.0, 0.0, 0.0), + transform(json!({"scaleH": 200, "scaleV": 200, "random": true, "strokes": true})), + transform(json!({"rotate": 90})), + ], + ); + let art = crate::reshape(None, &held, None).unwrap(); + let drawn = shadows_in(&art); + assert!(drawn.len() == 1 && drawn[0].0 > 0.0 && drawn[0].1.abs() < 1e-9, "{drawn:?}"); +} + +/// The 3D effects keep the shadow before a Transform effect that shapes the art they project, the +/// object's and its fill's 3D effect alike. +#[test] +fn a_shadow_before_a_transform_follows_it_into_3d_art() { + let before = vec![shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; + for three_d in [crate::EXTRUDE, crate::REVOLVE] { + let art = |n: &Node| if three_d == crate::EXTRUDE { crate::solid_art(n) } else { crate::revolve_art(n) }; + let mut on_object = before.clone(); + on_object.push(fx(three_d, json!({}))); + let projected = art(&square(14, on_object)).unwrap(); + assert!(near(&shadows_in(&projected), &[(20.0, 0.0)]), "{three_d} on the object: {:?}", shadows_in(&projected)); + let mut on_fill = square(15, before.clone()); + if let Some(item) = on_fill.appearance.items.first_mut() { + *item.effects_mut() = vec![fx(three_d, json!({}))]; + } + let projected = art(&on_fill).unwrap(); + assert!(near(&shadows_in(&projected), &[(20.0, 0.0)]), "{three_d} on the fill: {:?}", shadows_in(&projected)); + } +} + #[test] fn glows_blurs_and_feathers_scale_with_the_transform_after_them() { let effects = [ diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 1d39e2b4e4..169cc6102d 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -543,12 +543,14 @@ fn expand_piece_leaves( } /// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its -/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no +/// path (a plain path then), and every geometry effect of its own removed, with each raster effect +/// following the Transform effects after it +/// ([`without_geometry`](vectorcraft_render::effects::without_geometry)). `None` when it has no /// visible geometry effect. The swatches and patterns that live-effect plug-ins painting their /// pieces name are looked up in `doc`. This merges the pieces of such a plug-in into one path, so a /// path that one paints goes through [`expand_pieces`] instead. pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option { - use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, is_geometry}; + use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, without_geometry}; if !has_geometry(&l.appearance.effects) { return None; } @@ -559,7 +561,7 @@ pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option { if carry.moves(&m.appearance) { carry.apply(&mut m.appearance); } - m.appearance.effects.retain(|e| !is_geometry(&e.id)); + m.appearance.effects = without_geometry(&m.appearance.effects); Some(m) } @@ -601,9 +603,10 @@ fn with_path(l: &Node, path: PathData) -> Node { } /// The effects of fill `f` that stay on its expanded art: all but its geometry effects, which -/// shape the outline it paints ([`fill_shape`]). +/// shape the outline it paints ([`fill_shape`]), with each raster effect following the Transform +/// effects after it ([`without_geometry`](vectorcraft_render::effects::without_geometry)). fn fill_effects(f: &FillLayer) -> Vec { - f.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect() + vectorcraft_render::effects::without_geometry(&f.effects) } /// Replace every pattern fill of the selected paths by the tiles it paints (see [`expand_patterns`]). diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 5b9e25d354..c617c4de8e 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -896,7 +896,7 @@ pub(crate) fn outlined_stroke( }; let art = d.reid(&art); let mut art = with_transparency(d, art, st.opacity, st.blend); - let raster: Vec<_> = st.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect(); + let raster = vectorcraft_render::effects::without_geometry(&st.effects); if !raster.is_empty() { if !art.appearance.effects.is_empty() { art = Node::group(d.alloc_id(), vec![Arc::new(art)]); @@ -946,7 +946,7 @@ pub(crate) fn outlined_stroke( /// geometry effects leave nothing to paint, an empty group takes the path's place. /// `None` when `l` has no stroke to outline. pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option { - use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, is_geometry, reshape}; + use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, reshape, without_geometry}; if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) { return None; } @@ -975,7 +975,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br parts.push(styled_copy(d, l, std::mem::take(&mut fills))); } let mut plain = moved; - plain.effects_mut().retain(|e| !is_geometry(&e.id)); + *plain.effects_mut() = without_geometry(plain.effects()); parts.push(styled_copy(d, &shape, vec![plain])); } AppearanceItem::Stroke(st) if outlinable(st) => { @@ -983,7 +983,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br parts.push(styled_copy(d, l, std::mem::take(&mut fills))); } let mut plain = st.clone(); - plain.effects.retain(|e| !is_geometry(&e.id)); + plain.effects = without_geometry(&st.effects); let stroked = match path.bounds() { Some(b) if has_geometry(&st.effects) => apply_geometry_with(&st.effects, &path, b, &ctx.item(item)), _ => path.clone(), diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 84e5559851..da4ade9149 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -191,6 +191,27 @@ fn a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it() { } } +/// A Transform effect after a drop shadow, on the object or on the fill, scales and turns the +/// shadow on the canvas; Object › Expand of the gradient fill keeps the shadow the canvas drew. +#[test] +fn a_shadow_before_a_transform_expands_as_the_canvas_draws_it() { + for item in [json!(null), json!(0)] { + let mut s = session(); + let id = gradient_rect(&mut s, Rect::new(80.0, 80.0, 120.0, 120.0), white_black(), false); + for (effect, params) in [ + ("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100})), + ("distort.transform", json!({"scaleH": 200, "scaleV": 200, "rotate": 90})), + ] { + run(&mut s, "effect.apply", json!({"effect": effect, "params": params, "ids": [id.0], "item": item})); + } + let before = render(&s); + run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64})); + let after = render(&s); + let diff = differing(&before, &after, 64); + assert_eq!(diff, 0, "item {item}: {diff} pixels differ"); + } +} + #[test] fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() { // The object's own effect, and the fill's (the first appearance item). diff --git a/crates/engine/src/tests/outlinestroke.rs b/crates/engine/src/tests/outlinestroke.rs index d3394d1481..a92ca10416 100644 --- a/crates/engine/src/tests/outlinestroke.rs +++ b/crates/engine/src/tests/outlinestroke.rs @@ -308,6 +308,30 @@ fn the_outline_of_a_reshaped_stroke_keeps_its_drop_shadow() { assert_eq!(differing(&before, &render(&s), 64), 0, "the shadowed outline draws as the stroke did"); } +/// A Transform effect after a drop shadow, on the object or on the stroke (brushed or not), turns +/// and scales the shadow on the canvas; outlining the stroke keeps the shadow the canvas drew. +#[test] +fn a_shadow_before_a_transform_outlines_as_the_canvas_draws_it() { + let effects = [ + ("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100})), + ("distort.transform", json!({"scaleH": 50, "scaleV": 50, "rotate": 90})), + ]; + for (item, brush) in [(Value::Null, false), (json!(1), false), (json!(1), true)] { + // Flatten Transparency draws a brushed stroke's drop shadow differently with or without a + // Transform effect, so the brushed stroke is checked with the other two commands. + for (command, p) in outlining().into_iter().filter(|(command, _)| !(brush && *command == "object.flattenTransparency")) { + let mut s = session(); + square_with(&mut s, json!({"color": "#ff0000"}), item.clone(), &effects); + if brush { + s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap(); + } + let before = render(&s); + outlined_by(&mut s, command, &p); + assert_eq!(differing(&before, &render(&s), 64), 0, "{command}, item {item}, brush {brush}"); + } + } +} + /// The brush art of a brushed stroke with a geometry effect follows the path the effect makes. #[test] fn a_brush_draws_along_the_path_the_stroke_effects_make() { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b20aadde45..53d10f3290 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -589,7 +589,7 @@ since 2026-10-01. - **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. -- **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)), so a Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect is transformed with the art, with Scale Strokes & Effects on or off: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. Before, it was drawn at its own distances around the transformed art. The canvas, export and Expand Appearance agree (effects `tests::rasterfollow`, render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are, and a Rasterize effect before a Transform effect keeps its pixel grid and room around the art. Scores and hours unchanged. +- **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). A Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect in the same list (an object's effects, or a fill's or stroke's own) now follows that Transform effect: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. VectorCraft applies this rule with Scale Strokes & Effects on or off. Before, such an effect was drawn at its own distances around the transformed art. The canvas, export, Expand Appearance, Object › Expand, Outline Stroke and the 3D effects use the same values (effects `tests::rasterfollow`; render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`; engine `objexpand::a_shadow_before_a_transform_expands_as_the_canvas_draws_it`, `outlinestroke::a_shadow_before_a_transform_outlines_as_the_canvas_draws_it`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are; a Rasterize effect before a Transform effect keeps its pixel grid and room around the art; the angles and light directions of pixel filters do not turn or reflect with the art. An object's or group's Transform effect changes the effects of its fills, strokes and members only through Scale Strokes & Effects, as before. Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. From 5036099b51cb083edcc516e4d9ebd46ab4d06944 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:57:26 -0800 Subject: [PATCH 12/34] Effects: a copying Transform effect after 3D holds raster effects back; pixel filter sizes follow - A Transform effect that makes copies or moves at random, listed after an Extrude or Revolve effect, now keeps the raster effects before the 3D effect at their own values, as it does without the 3D effect. Before, the 3D art kept a Drop Shadow scaled by a Transform effect between them. - In a fill's or stroke's own effects, such a Transform effect also keeps the raster effects before it from following an earlier Transform effect with Transform Patterns on, and the Transform effects between them. - Colored Pencil's pencilWidth and Paint Daubs' brushSize are distances in document units, so a Transform effect after them and Scale Strokes & Effects scale them as they scale Palette Knife's strokeSize. The Paint Daubs params text names the unit. --- crates/effects/src/catalog.rs | 4 +- crates/effects/src/reshape.rs | 22 ++++++++--- crates/effects/src/solids.rs | 11 +++++- crates/effects/src/tests/rasterfollow.rs | 48 ++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 8 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 8edea9d535..9f62024150 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -94,6 +94,8 @@ fn lengths_of(id: &str) -> Lengths { "stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]), "brushStrokes.accentedEdges" => always(&["edgeWidth"]), "artistic.paletteKnife" => always(&["strokeSize"]), + "artistic.coloredPencil" => always(&["pencilWidth"]), + "artistic.paintDaubs" => always(&["brushSize"]), "brushStrokes.angledStrokes" | "brushStrokes.crosshatch" | "brushStrokes.inkOutlines" => always(&["strokeLength"]), "brushStrokes.spatter" => always(&["sprayRadius"]), "brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]), @@ -387,7 +389,7 @@ pub fn effect_catalog() -> Vec { "artistic.paintDaubs", "Paint Daubs…", ARTISTIC, - "{brushSize: 1..50 (10; dab centres farther apart), sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + "{brushSize: 1..50 (10; spacing of the dab centers in document units),sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", json!({"brushSize": 10.0, "sharpness": 25.0, "brushType": "simple"}), ), r( diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index b25c016cfd..6ddc4b8c0e 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -1263,9 +1263,13 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt let params = merged_params(&fx[at].id, &fx[at].params); // Where each effect the piece keeps sits among the item's effects. let kept: Vec = (0..at).filter(|&k| !is_geometry(&fx[k].id)).chain(at + 1..fx.len()).collect(); + // A Transform effect after this one that makes copies or moves at random holds the raster + // effects before this one back from every Transform effect ([`crate::raster::followed`]). + let held_later = fx.get(at + 1..).unwrap_or_default().iter().any(crate::raster::holds_back); // The effects before the Transform effect that it keeps, each raster effect following the - // geometry effects before it ([`crate::raster::without_geometry`]). - let kept_before = crate::without_geometry(before); + // geometry effects before it ([`crate::raster::without_geometry`]) unless held back. + let kept_before: Vec = + if held_later { before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect() } else { crate::without_geometry(before) }; if let Some(plugin) = painted_plugin(&fx[at]) { let mut it = item.clone(); let rest: Vec = kept_before.iter().chain(&fx[at + 1..]).cloned().collect(); @@ -1308,7 +1312,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt let (made, moved) = transform_leaf(&mut p, &g, map, copies, options, room); *used = used.plus(made); let from = crate::raster::held_back(before); - if moved { + if moved && !held_later { follow_piece(&mut p, kept_before.get(from..).unwrap_or_default(), from, &fx[at]); } transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(&fx[..=at]), limit, used, doc); @@ -1333,7 +1337,8 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt /// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path /// for each piece it returns ([`paint_piece`]). The raster effects before a Transform effect /// follow it ([`follow_piece`]), except the first `held` effects, which a Transform effect before -/// the piece's effects that makes copies or moves at random holds back. The geometry effects then +/// the piece's effects that makes copies or moves at random holds back, and the effects before +/// such a Transform effect among the piece's effects. The geometry effects then /// leave the fills and strokes; their other effects stay. The paths keep to `max` together with /// the rest of the art (`used`), and the swatches and patterns painted pieces name must be in /// `doc`. `(seed, source, kept)`: the object's seed, the index in the object's appearance of the @@ -1346,7 +1351,14 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h count = count.max(item.effects().len()); } }); + // The effects before a Transform effect among the piece's effects that makes copies or moves at + // random are held back from the Transform effects before it too. let mut held = held; + for_each_leaf(p, &mut |l| { + if let Some(last) = l.appearance.items.first().and_then(|item| item.effects().iter().rposition(crate::raster::holds_back)) { + held = held.max(last); + } + }); for j in 0..count { let Some(bounds) = p.geometric_bounds() else { break }; let from = held.min(j); @@ -1397,7 +1409,7 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h follow_piece(leaf, was, from, &e); }); if holds { - held = j; + held = held.max(j); } } for_each_leaf(p, &mut |leaf| { diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 4d04a4009a..f71d79fb60 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -961,10 +961,17 @@ fn group_solid_art(n: &Node) -> Option { /// `effects` without the 3D effect at `index` and the geometry effects before it, which shape the /// art it projects. Each raster effect before it follows the Transform effects among those -/// ([`crate::without_geometry`]); the effects after it stay as they are. +/// ([`crate::without_geometry`]), unless a Transform effect after it makes copies or moves at +/// random ([`crate::raster::holds_back`]); the effects after it stay as they are. pub(crate) fn past_effect(effects: &[Effect], index: usize) -> Vec { let (before, after) = effects.split_at(index.min(effects.len())); - crate::without_geometry(before).into_iter().chain(after.iter().skip(1).cloned()).collect() + let after = after.get(1..).unwrap_or_default(); + let kept: Vec = if after.iter().any(crate::raster::holds_back) { + before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect() + } else { + crate::without_geometry(before) + }; + kept.into_iter().chain(after.iter().cloned()).collect() } /// One solid of a 3D object as a mesh, with the color it is painted. diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index 5d578d15a8..875d916940 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -264,6 +264,54 @@ fn glows_blurs_and_feathers_scale_with_the_transform_after_them() { assert_eq!(crate::outset(&shadowed, Rect::new(0.0, 0.0, 100.0, 100.0)), 20.0); } +#[test] +fn pixel_filter_sizes_scale_with_the_transform_after_them() { + let effects = [ + fx("artistic.coloredPencil", json!({"pencilWidth": 5})), + fx("artistic.paintDaubs", json!({"brushSize": 10})), + transform(json!({"scaleH": 200, "scaleV": 200})), + ]; + let drawn = raster_effects(&effects); + assert!(matches!(drawn.first(), Some(RasterFx::Pixel(crate::PixelFx::ColoredPencil { width, .. })) if (width - 10.0).abs() < 1e-9), "{drawn:?}"); + assert!(matches!(drawn.get(1), Some(RasterFx::Pixel(crate::PixelFx::PaintDaubs { size, .. })) if (size - 20.0).abs() < 1e-9), "{drawn:?}"); +} + +/// A Transform effect after a 3D effect that makes copies or moves at random holds back the shadow +/// before the 3D effect, as it does without the 3D effect. +#[test] +fn a_copying_transform_after_3d_holds_the_shadow_back() { + for later in [json!({"copies": 1}), json!({"scaleH": 150, "scaleV": 150, "random": true})] { + let plain = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), transform(later.clone())]; + assert!(close(drawn_shadow(&plain), (10.0, 0.0, 0.0)), "{later}: {:?}", drawn_shadow(&plain)); + for three_d in [crate::EXTRUDE, crate::REVOLVE] { + let art = |n: &Node| if three_d == crate::EXTRUDE { crate::solid_art(n) } else { crate::revolve_art(n) }; + let effects = vec![plain[0].clone(), plain[1].clone(), fx(three_d, json!({})), plain[2].clone()]; + let projected = art(&square(16, effects)).unwrap(); + let drawn = shadows_in(&projected); + assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{three_d} {later}: {drawn:?}"); + } + } +} + +/// In a fill's own effects, a Transform effect that makes copies after a Transform effect with +/// Transform Patterns on holds back the shadow before both, and before a Transform effect between +/// them. +#[test] +fn a_fills_copying_transform_holds_back_the_shadow_before_an_earlier_transform() { + let patterns = transform(json!({"scaleH": 200, "scaleV": 200, "patterns": true})); + let copies = transform(json!({"copies": 1})); + let lists = [ + vec![shadow(10.0, 0.0, 0.0), patterns.clone(), copies.clone()], + vec![shadow(10.0, 0.0, 0.0), patterns, transform(json!({"rotate": 90})), copies], + ]; + for effects in lists { + let n = fill_square(17, effects.clone()); + let art = crate::reshape(None, &n, None).unwrap(); + let drawn = shadows_in(&art); + assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{} effects: {drawn:?}", effects.len()); + } +} + #[test] fn expanded_and_exported_objects_keep_the_shadow_the_canvas_draws() { let effects = vec![shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; From ff6dac4a5d659074a997a687679a7593abdd8dbd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 08:33:29 -0800 Subject: [PATCH 13/34] Shaper: a click on an original shape's outline enters Construction mode With a Shaper Group or one of its faces selected, a Shaper click within 3 screen pixels of one of the group's original shapes' outlines enters Construction mode with that shape selected, also where the outline runs through a merged face. shaper.select takes a tolerance in points (default 3, at most 1000); hidden and locked shapes are skipped, and a Shaper Group above the selected one still takes the click. --- crates/engine/src/cmd/shaper.rs | 44 ++++++++++++++-- crates/engine/src/tests/shaper.rs | 87 +++++++++++++++++++++++++++++++ crates/tools/src/extra.rs | 5 +- docs/shaper.md | 2 +- docs/target-app-parity.md | 1 + 5 files changed, 132 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/shaper.rs b/crates/engine/src/cmd/shaper.rs index 44ec938bc2..228251fa13 100644 --- a/crates/engine/src/cmd/shaper.rs +++ b/crates/engine/src/cmd/shaper.rs @@ -2,7 +2,9 @@ //! art retained underneath. Recipes select topology regions; source edits re-evaluate them in //! the same undo transaction. Rendering and exporters only see ordinary groups and paths. use super::pathops::Beside; -use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, object_param, point_param}; +use super::{ + CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, number_or, object_param, point_param, +}; use serde_json::{Value, json}; use std::{ collections::{BTreeMap, HashMap}, @@ -34,7 +36,7 @@ pub fn specs() -> Vec { "Select Shaper Face", [], None, - "{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original (while an opacity mask or a pattern is edited, it selects the original and the editing layer stays isolated); while a group's originals are isolated, it selects one of that group's originals → {id}", + "{point: [x,y], source?: bool, tolerance?: pt (3)} click selects a group, then a face; with the group or one of its faces selected, a click within tolerance of an original shape's outline enters construction isolation and selects that original; source enters construction isolation and selects an original (while an opacity mask or a pattern is edited, it selects the original and the editing layer stays isolated); while a group's originals are isolated, it selects one of that group's originals → {id}", has_doc, select ), @@ -734,16 +736,50 @@ fn group_at(d: &Document, point: Point, construction: bool, within: Option Option<(NodeId, NodeId)> { + let mut hit = None; + let editable = d.editable(); + d.walk(|n| { + if n.extras().shaper.is_none() || !editable(n.id) { + return; + } + let picked = selection.contains(n.id) || selection.objects.iter().any(|id| d.parent_of(*id) == Some(n.id)); + let on_outline = + |s: &&Arc| b::node_outline(s).is_some_and(|(path, _)| path.nearest(point).is_some_and(|(_, _, _, _, dist)| dist <= tolerance)); + let source = if picked { sources(n).iter().rev().filter(|s| s.visible && !s.locked).find(on_outline).map(|s| s.id) } else { None }; + if let Some(source) = source { + hit = Some((n.id, source)); + } else if n.geometric_bounds().is_some_and(|b| b.inflate(6.0, 6.0).contains(point)) { + hit = None; + } + }); + hit +} fn select(s: &mut Session, p: &Value) -> Result { let point = point_param(p, "point").filter(|p| finite(*p)).ok_or_else(|| bad("shaper.select", "missing point"))?; let st = s.doc()?; let source = bool_or(p, "source", false); + let tolerance = number_or(p, "tolerance", 3.0, "shaper.select")?; + if !tolerance.is_finite() || !(0.0..=1000.0).contains(&tolerance) { + return Err(bad("shaper.select", "invalid tolerance")); + } // In Construction mode a source pick stays in the group whose source shapes are isolated. let isolated = st.isolation.and_then(|i| st.doc.parent_of(i)).filter(|g| st.doc.node(*g).and_then(source_group).is_some_and(|s| st.isolation == Some(s.id))); - let Some(group) = group_at(&st.doc, point, source, isolated.filter(|_| source)) else { return Ok(json!({"id": null})) }; + // With a group or one of its faces selected, a click on one of its original shapes' outlines + // enters Construction mode with the topmost such shape selected, wherever the outline lies. + let outline = if source { None } else { outline_at(&st.doc, &st.selection, point, tolerance) }; + let Some(group) = outline.map(|(g, _)| g).or_else(|| group_at(&st.doc, point, source, isolated.filter(|_| source))) else { + return Ok(json!({"id": null})); + }; let node = st.doc.node(group).ok_or(EngineError::NoNode(group))?; - let target = if source { + let source = source || outline.is_some(); + let target = if let Some((_, id)) = outline { + id + } else if source { sources(node) .iter() .rev() diff --git a/crates/engine/src/tests/shaper.rs b/crates/engine/src/tests/shaper.rs index 891d03e7a7..38e61241f0 100644 --- a/crates/engine/src/tests/shaper.rs +++ b/crates/engine/src/tests/shaper.rs @@ -259,6 +259,93 @@ fn shaper_double_click_selects_an_original_for_construction_editing() { assert_eq!(s.doc().unwrap().selection.objects, vec![g]); } +/// With a Shaper Group or one of its faces selected, a Shaper click on an original shape's outline +/// enters Construction mode with that shape selected, even where the outline lies inside a merged +/// face. A click away from every outline still selects a face, and a click on an outline of a group +/// that is not selected selects the group. +#[test] +fn shaper_click_on_an_original_outline_enters_construction_mode() { + let mut s = session(); + let a = rectangle(&mut s, 0.0, "#ff0000"); + let b = rectangle(&mut s, 50.0, "#0000ff"); + let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]); + let sources = node(&s, g).children().unwrap()[0].id; + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("shaper", v).unwrap(); + let click = |s: &mut Session, x: f64, y: f64| { + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, x, y), v).unwrap(); + } + }; + s.execute("select.none", &json!({})).unwrap(); + click(&mut s, 101.0, 50.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![g]); + assert_eq!(s.doc().unwrap().isolation, None); + // The right side of the back shape runs through the merged face. + click(&mut s, 101.0, 50.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + assert_eq!(s.doc().unwrap().isolation, Some(sources)); + s.execute("object.exitIsolation", &json!({})).unwrap(); + // A click inside the face, away from the outlines, selects the face. + click(&mut s, 75.0, 50.0); + let face = s.doc().unwrap().selection.objects.clone(); + assert_eq!(face.len(), 1); + assert_ne!(face, vec![g]); + assert_eq!(s.doc().unwrap().isolation, None); + // With the face selected, a click on the front shape's outline picks that shape. + click(&mut s, 149.0, 50.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![b]); + assert_eq!(s.doc().unwrap().isolation, Some(sources)); + s.execute("object.exitIsolation", &json!({})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + for tolerance in [json!("3"), json!(-1), json!(1e9)] { + assert!(s.execute("shaper.select", &json!({"point": [101, 50], "tolerance": tolerance})).is_err(), "{tolerance}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![g]); + assert_eq!(s.doc().unwrap().isolation, None); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + s.execute("shaper.select", &json!({"point": [104, 50], "tolerance": 5})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + s.execute("object.exitIsolation", &json!({})).unwrap(); + // A wide tolerance (a zoomed-out view) reaches an outline from well outside the group's bounds. + s.execute("shaper.select", &json!({"point": [165, 50], "tolerance": 20})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![b]); + assert_eq!(s.doc().unwrap().isolation, Some(sources)); + // A locked or hidden original shape is not picked by its outline. + for hide in ["object.lock", "object.hide"] { + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + s.execute(hide, &json!({})).unwrap(); + s.execute("object.exitIsolation", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + s.execute("shaper.select", &json!({"point": [149, 50]})).unwrap(); + assert_ne!(s.doc().unwrap().selection.objects, vec![b], "{hide}"); + assert_eq!(s.doc().unwrap().isolation, None, "{hide}"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + } +} + +/// A click on a selected Shaper Group's original outline where another Shaper Group lies above it +/// selects the group above, as a click there did before. +#[test] +fn shaper_outline_click_under_another_group_selects_the_group_above() { + let mut s = session(); + rectangle(&mut s, 0.0, "#ff0000"); + rectangle(&mut s, 50.0, "#0000ff"); + let lower = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]); + let c = rectangle(&mut s, 20.0, "#00ff00"); + let d = rectangle(&mut s, 70.0, "#ffff00"); + let upper = NodeId( + s.execute("shaper.scribble", &json!({"points": [[45, 20], [95, 35], [50, 50], [145, 65]], "ids": [c.0, d.0]})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("select.set", &json!({"ids": [lower.0]})).unwrap(); + s.execute("shaper.select", &json!({"point": [100, 50]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![upper]); + assert_eq!(s.doc().unwrap().isolation, None); +} + /// The arrow widget beside a selected Shaper Group switches it to Construction mode and back, with /// the Shaper tool and with the Selection tool. In Construction mode a Shaper tap selects a source /// shape, and the mode lasts through undo and redo of an edit made in it. diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index 4f8e867a54..d1a2672402 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -559,10 +559,11 @@ impl Tool for ShaperTool { } let pts = std::mem::take(&mut self.points); if pts.first().is_some_and(|first| pts.iter().all(|p| p.distance(*first) < cx.tol(3.0))) { - // In Construction mode a tap selects a source shape. + // In Construction mode a tap selects a source shape. On a selected group, a tap on a + // source shape's outline enters Construction mode. let construction = cx.isolation.and_then(|id| cx.doc.node(id)).is_some_and(|n| n.name.as_deref() == Some(vectorcraft_doc::shaper::SOURCES)); - let mut params = json!({"point": [ev.pos.x, ev.pos.y]}); + let mut params = json!({"point": [ev.pos.x, ev.pos.y], "tolerance": cx.tol(3.0).min(1000.0)}); if construction { params["source"] = json!(true); } diff --git a/docs/shaper.md b/docs/shaper.md index d32659478e..2318edd8a8 100644 --- a/docs/shaper.md +++ b/docs/shaper.md @@ -9,7 +9,7 @@ Scribble over overlapping paths to make an editable Shaper Group: - End the scribble outside the artwork to remove the touched fills and exposed boundary strokes. Shared edges along surviving filled areas remain, closing their contours. Compatible stroke pieces join at the new corners, so the selected stroke’s join style applies there. - Scribble over an unfilled stroke segment to remove that piece. A line passing through a shape is split at the intersections, so its protruding ends can be removed separately. -Click once to select the group; click again to select its visible fill and change its color. Double-click with Shaper to enter construction editing and select an original shape. The Selection tool then moves or resizes it normally, and the visible composition updates. Escape leaves construction editing. The original live shapes and edit recipes survive native save/reopen. Outside construction editing, selection highlights and bounds show only the visible result. Outside construction editing, a marquee selects the group or its faces only where it reaches the visible result, and Direct Selection's clicks and marquee select only the anchor points of the visible result. +Click once to select the group; click again to select its visible fill and change its color. Double-click with Shaper to enter construction editing and select an original shape. With the group or one of its faces selected, a Shaper click on an original shape's outline also enters construction editing and selects that shape, even where the outline runs through a merged face ([help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/create-shapes-with-the-shaper-tool.html)). The click counts within 3 screen pixels of the outline, a VectorCraft value. The Selection tool then moves or resizes it normally, and the visible composition updates. Escape leaves construction editing. The original live shapes and edit recipes survive native save/reopen. Outside construction editing, selection highlights and bounds show only the visible result. Outside construction editing, a marquee selects the group or its faces only where it reaches the visible result, and Direct Selection's clicks and marquee select only the anchor points of the visible result. While the group or one of its faces is selected (Face Selection mode) and the Shaper or Selection tool is active, an arrow widget sits beside the middle of the right side of the original shapes’ bounds. Click it to enter Construction mode: the original shapes are isolated and nothing is selected, and a click with Shaper selects the group’s original shape under the pointer. Click the widget again to leave Construction mode with the group selected. The arrow points right in Face Selection mode and left in Construction mode. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d42a4d988b..15d48f1dbc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -926,6 +926,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Tools:** - **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing, and with the Shaper or Selection tool a click on the arrow widget beside a Shaper Group's original shapes switches the group between Face Selection and Construction mode. Native save/reopen, face recoloring, Release and Expand are covered. Finer gesture heuristics remain. In Construction mode, a Selection-tool drag that drops an original shape wholly outside the bounds of the group's original shapes takes it out of the group in the move's undo step (Remove from Shaper Group, `shaper.releaseSource`): the shape becomes a path above the group and stays selected, and a group left with no shapes is removed. Engine tests drag a shape out of a two-shape group and back across the bounds, take the last shape out, undo, and save the result. + With a Shaper Group or one of its faces selected, a Shaper click within 3 screen pixels of an original shape's outline enters Construction mode with that shape selected, even where the outline runs through a merged face (`shaper.select`'s `tolerance`; [help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/create-shapes-with-the-shaper-tool.html)). Test: `tests::shaper::shaper_click_on_an_original_outline_enters_construction_mode`. - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it). The search for the segment of a path nearest a point, which Direct Selection runs to pick the cursor over a segment, skips each segment whose anchors and handles lie in a box farther from the point than a segment already measured, starting with a segment at the anchor nearest the point. The segment found is the same as before. Over a segment of an open path of 200,000 anchors, the cursor takes 51 ms instead of 59 ms in a release build (`cargo run --release -p vectorcraft-engine --example anchor_bench`). A Direct Selection marquee over part of a segment, with neither of its anchors inside, selects that segment as a click on it does (Shift toggles it), as [Selecting objects](http://web.archive.org/web/20211230015528/https://helpx.adobe.com/illustrator/using/selecting-objects.html) describes (#4654). From 2d66758ce480eec4b667e52489bf9171ef9bfd68 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 09:19:15 -0800 Subject: [PATCH 14/34] MCP: document.json checks brush art and relative link paths against the read roots Under --automation-read-root, document.json names a linked image or placed file in a stored brush's art outside the read roots by its file name alone, as it does in the layers. A stored brush entry that does not read as a brush is left out of the reply, and a stored brush library that is not a list becomes an empty one. A link whose file is inside the read roots loses its stored relative path when that path leads outside them, from the folder of the file the document was read from or from the document's own folder. --- crates/engine/src/cmd/edit.rs | 4 +- crates/engine/src/cmd/links.rs | 42 ++++++++++++++++++--- crates/engine/src/tests/placed_document.rs | 44 ++++++++++++++++++++++ docs/mcp.md | 8 ++-- docs/target-app-parity.md | 2 +- 5 files changed, 88 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 8b1e147625..8c86bbd777 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -50,9 +50,9 @@ pub fn specs() -> Vec { has_doc, doc_compare ), - cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, and so is a data set's image value outside them; a folder outside them is left out of export_settings)", has_doc, |s, _| { + cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, in brush art too, and so is a data set's image value outside them; a relative path that leads outside them is left out; a stored brush entry that does not read as a brush is left out; a folder outside them is left out of export_settings)", has_doc, |s, _| { let st = s.doc()?; - let d = super::links::with_hidden_paths_named(&st.doc, super::links::LinkBase::of(st).doc); + let d = super::links::with_hidden_paths_named(&st.doc, super::links::LinkBase::of(st)); Ok(serde_json::to_value(&*d).unwrap_or(Value::Null)) }), cmd!( diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index effa917d8e..0f2d6ea1e7 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -1202,27 +1202,57 @@ pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) { /// allow names its file alone, without its relative path or the size and date kept of its file, /// and each data set's image value it does not allow names its file alone too. The folder Export /// for Screens last wrote to is left out of the export settings when it is outside the read roots. -/// Links in the layers, symbols, graph designs, patterns, the pattern being edited as it was -/// before and opacity masks are all checked. -pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, doc_path: Option<&str>) -> Cow<'a, Document> { +/// A link whose path is allowed but whose relative path leads outside the read roots loses its +/// relative path. Links in the layers, symbols, graph designs, patterns, the pattern being edited +/// as it was before, opacity masks and the art of the stored brushes are all checked. A stored +/// brush entry that does not read as a brush is left out of the copy, since its links cannot be +/// checked, and a stored brush library that is not a list becomes an empty one. +pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, base: LinkBase) -> Cow<'a, Document> { if crate::file_access::current().is_none() { return Cow::Borrowed(d); } + let doc_path = base.doc; + // A stored relative path is followed from the folder of the file the document was read from, + // and a link is also looked for from the document's own folder: it is shown only when both + // places it names are inside the read roots. + let relative_hidden = |r: &str| [base.relative, base.doc].into_iter().any(|from| !path_shown(r, from)); let mut d = d.clone(); // The pattern being edited as it was before is checked as one more pattern, then put back. let original = d.pattern_edit.as_mut().and_then(|e| e.original.take()); let edited = original.is_some(); d.patterns.extend(original); + // The art of each stored brush is checked as more layers, then put back. + let stored = d.unknown.get(vectorcraft_brush::DOC_KEY).map(|v| v.as_array().cloned().unwrap_or_default()); + let mut brushes: Vec = + stored.iter().flatten().filter_map(|raw| vectorcraft_brush::Brush::from_json(raw.clone()).ok()).collect(); + let layers = d.layers.len(); + d.layers.extend(brushes.iter().flat_map(|b| b.art()).map(|n| Arc::new(n.clone()))); d.update_links_where( - |l| !path_shown(&l.path, doc_path), + |l| !path_shown(&l.path, base.doc) || l.relative.as_deref().is_some_and(relative_hidden), |l| { - let name = l.name().to_string(); - *l = LinkInfo { path: name, relative: None, modified: None, size: None, ..l.clone() }; + if path_shown(&l.path, base.doc) { + l.relative = None; + } else { + let name = l.name().to_string(); + *l = LinkInfo { path: name, relative: None, modified: None, size: None, ..l.clone() }; + } }, ); + let mut checked = d.layers.split_off(layers).into_iter(); if edited && let (Some(def), Some(e)) = (d.patterns.pop(), d.pattern_edit.as_mut()) { e.original = Some(def); } + if stored.is_some() { + // Each brush is written as it reads, so fields it does not read are left out too. + let mut kept = vec![]; + for b in &mut brushes { + for (slot, art) in b.art_mut().into_iter().zip(&mut checked) { + *slot = Arc::unwrap_or_clone(art); + } + kept.extend(serde_json::to_value(&*b).ok()); + } + d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(kept)); + } if d.export_settings.get("folder").and_then(Value::as_str).is_some_and(|f| crate::file_access::check_read(f).is_err()) { d.export_settings.remove("folder"); } diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 3ec0310430..1a6ee921f2 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -1136,6 +1136,50 @@ fn document_replies_leave_out_paths_outside_the_read_roots() { assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["path"], json!(poster)); } +/// `document.json` for a confined caller: a linked image in a brush's art outside the read roots +/// is named by its file name, a stored brush entry that does not read as a brush is left out, and a +/// link inside them whose stored relative path leads outside them loses that relative path. +#[test] +fn document_json_checks_brush_art_and_relative_paths_against_the_read_roots() { + let away = Folder::new("doc-brr-away"); + let home = Folder::new("doc-brr-home"); + let pic = away.file("photo.png"); + write(&pic, &crate::tests_links::png(60, 30, BLUE)); + let near_pic = home.file("near.png"); + write(&near_pic, &crate::tests_links::png(20, 10, RED)); + let mut s = session(); + let r = s.execute("file.place", &json!({"path": pic, "at": [50, 50]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + let image = r["ids"][0].as_u64().unwrap(); + let r = s.execute("file.place", &json!({"path": near_pic, "at": [150, 50]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + s.execute("brush.new", &json!({"type": "art", "name": "Photo Band", "ids": [image]})).unwrap(); + save(&mut s, &home.file("poster.vectorcraft")); + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let lib = d.unknown.get_mut(vectorcraft_brush::DOC_KEY).and_then(Value::as_array_mut).unwrap(); + lib.push(json!({"name": "Broken Band", "type": "unknown kind", "note": pic})); + d.update_links(|l| { + if l.name() == "near.png" { + l.relative = Some("../doc-brr-away/near.png".into()); + } + }); + } + let unconfined = s.execute("document.json", &json!({})).unwrap().to_string(); + for part in [json!(pic).to_string(), "Broken Band".into(), "../doc-brr-away/near.png".into()] { + assert!(unconfined.contains(&part), "{part}: {unconfined}"); + } + let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap(); + let model = crate::file_access::confine(Some(&roots), || s.execute("document.json", &json!({}))).unwrap(); + let text = model.to_string(); + assert!(!text.contains("doc-brr-away") && !text.contains("Broken Band"), "{text}"); + assert!(text.contains("\"path\":\"photo.png\"") && text.contains("Photo Band"), "{text}"); + assert!(text.contains(&json!(near_pic).to_string()), "{text}"); + // The document keeps its brushes and links. + let stored = s.doc().unwrap().doc.unknown.get(vectorcraft_brush::DOC_KEY).unwrap().to_string(); + assert!(stored.contains("Broken Band") && stored.contains(&json!(pic).to_string()), "{stored}"); +} + /// Save As reads its defaults from the catalog; using them must retain current editable links. #[test] fn native_save_catalog_defaults_keep_placed_documents_editable() { diff --git a/docs/mcp.md b/docs/mcp.md index 47f425482f..aee76d3710 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -172,9 +172,11 @@ the suite) keep the agent to the folders you give it: read roots, and the preset stays. The preferences themselves are not changed. - `document.inspect` (`inspect_document`) gives `path` as null when the document's file is outside the read roots. `document.json` names a linked image or placed file outside them by its file name alone in the link's `path`, and - leaves out the link's `relative`, `size` and `modified`. It also names a data set's image value outside them by - its file name alone, and leaves out the `folder` of `export_settings` (the settings Export for Screens last used) - when that folder is outside them. The document itself is not changed. + leaves out the link's `relative`, `size` and `modified`. This covers the art of the brushes in `unknown.brushes` + too. A link whose file is inside them keeps its `path` but leaves out a `relative` that leads outside them. A + stored brush entry that does not read as a brush is left out of `unknown.brushes`. `document.json` also names a data set's image + value outside them by its file name alone, and leaves out the `folder` of `export_settings` (the settings Export + for Screens last used) when that folder is outside them. The document itself is not changed. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d4a501dca5..df8e93326c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -764,7 +764,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder and names that folder by its name in an error, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). - - Document replies under `--automation-read-root`: `document.inspect` gives `path` as null for a document file outside the read roots, and `document.json` names a linked image or placed file outside them by its file name alone, without its relative path, size or date, and a data set's image value outside them by its file name, and leaves an Export for Screens folder outside them out of `export_settings`; the document is not changed. `crates/engine/src/tests/placed_document.rs` (`document_replies_leave_out_paths_outside_the_read_roots`). + - Document replies under `--automation-read-root`: `document.inspect` gives `path` as null for a document file outside the read roots, and `document.json` names a linked image or placed file outside them by its file name alone, without its relative path, size or date, brush art included, leaves out a relative path that leads outside them and a stored brush entry that does not read as a brush, names a data set's image value outside them by its file name, and leaves an Export for Screens folder outside them out of `export_settings`; the document is not changed. `crates/engine/src/tests/placed_document.rs` (`document_replies_leave_out_paths_outside_the_read_roots`, `document_json_checks_brush_art_and_relative_paths_against_the_read_roots`). - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. From 6213d1c471a5ea0c8395d88b6556adcd5116e2dc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 08:56:23 -0800 Subject: [PATCH 15/34] EPS tests: the reader's long tests check counts and shared state instead of a 20 s limit many_tiling_patterns_and_spot_inks_read_in_linear_time checked that a program setting 200,000 different tiling patterns and 200,001 spot inks reads within 20 s of wall-clock time. It reads in about 4 s alone; while the crate's full test suite ran in parallel it took more than 20 s and failed. In test builds, the tiling pattern lookup now counts on its thread how many patterns it compared with the one it looked for (tilings_compared, gated with cfg(test) like SELECTOR_TESTS in the SVG reader's CSS). The test, renamed many_tiling_patterns_are_found_again_through_a_hash, reads through import on a thread of its own and asserts that its 200,000 lookups compared at most 200,000 patterns in all. When every pattern hashes alike, as when each lookup went through every pattern made so far, the lookups compare 19,999,900,000 and the test fails. The test still reads the 200,001 spot inks and checks their swatches, but it does not count or time their lookups. The other tests that read through read_soon wait 300 s instead of 20 s, as a guard against a read that never ends; their budgets and limits decide what they check. work_on_the_longest_dash_pattern_ends_soon (4.5 s alone) now also checks that the states gsave, grestore, gstate, currentgstate and setgstate save, make or set hold the one 65,535-entry dash pattern, so a copy of the pattern in any of them fails it. --- crates/eps/src/import/graphics.rs | 2 + crates/eps/src/import/graphics/paint.rs | 21 ++++++- crates/eps/src/import/tests.rs | 75 ++++++++++++++++++++----- 3 files changed, 82 insertions(+), 16 deletions(-) diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index 46fd148db9..3ff736d1ff 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -24,6 +24,8 @@ mod state; mod strips; pub(crate) use paint::collapse; +#[cfg(test)] +pub(crate) use paint::tilings_compared; pub(crate) use state::{GState, Out, Space, process}; pub(crate) use strips::merge_strips; diff --git a/crates/eps/src/import/graphics/paint.rs b/crates/eps/src/import/graphics/paint.rs index fc2490fb41..156b6e421e 100644 --- a/crates/eps/src/import/graphics/paint.rs +++ b/crates/eps/src/import/graphics/paint.rs @@ -5,6 +5,19 @@ use super::{ PsError, REGISTRATION, Rc, Rect, Res, SHADING_SAMPLES, Shared, UNKNOWN_FUNCTIONS, UNKNOWN_SHADINGS, Vec2, clamp_between, ps_err, }; +#[cfg(test)] +thread_local! { + /// How many patterns the lookups of tiling patterns on this thread compared with the pattern + /// they looked for. + static TILINGS_COMPARED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// [`TILINGS_COMPARED`] of this thread. +#[cfg(test)] +pub(crate) fn tilings_compared() -> u64 { + TILINGS_COMPARED.get() +} + impl Interp<'_> { // ---------- colour ---------- @@ -212,7 +225,13 @@ impl Interp<'_> { let proc = get("PaintProc").ok_or(PsError::Ps("undefined", "PaintProc".into()))?; let key = proc.items().cloned().ok_or(PsError::Ps("typecheck", "PaintProc".into()))?; let hash = tiling_hash(&key, tile, under.as_ref()); - let made = self.out.tilings.get(&hash).and_then(|list| list.iter().find(|(p, t, u, _)| p.same(&key) && *t == tile && *u == under)); + let made = self.out.tilings.get(&hash).and_then(|list| { + list.iter().find(|(p, t, u, _)| { + #[cfg(test)] + TILINGS_COMPARED.set(TILINGS_COMPARED.get().saturating_add(1)); + p.same(&key) && *t == tile && *u == under + }) + }); if let Some((.., name)) = made { return Ok(Paint::Pattern { pattern: name.clone(), xf }); } diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 6a1df810bc..4709a3c5bf 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -7,6 +7,9 @@ use vectorcraft_color::{Color, GradientKind, Paint}; use vectorcraft_doc::{AppearanceItem, ColorMode, Document, LineCap, LineJoin, Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; +use crate::import::graphics::{GState, Out, tilings_compared}; +use crate::import::interp::Interp; +use crate::import::obj::Obj; use crate::import::{Dsc, import}; use crate::{EpsOptions, Level, Preview, Raster}; @@ -369,12 +372,19 @@ fn dash_patterns_hold_at_most_65535_entries() { /// Saved and copied graphics states share the dash pattern, and `setdash`, `currentdash` and /// each stroke count the entries they read or copy against the budgets. Under a pattern of -/// 65,535 entries, 4,000,000 passes of `gsave grestore` and 2,000,000 passes of -/// `gstate currentgstate setgstate` finish, and loops of `setdash`, `currentdash` and `strokepath` -/// run out of a budget. +/// 65,535 entries, the states that `gsave`, `grestore`, `gstate`, `currentgstate` and `setgstate` +/// save, make or set hold that one pattern, 4,000,000 passes of `gsave grestore` and 2,000,000 +/// passes of `gstate currentgstate setgstate` finish, and loops of `setdash`, `currentdash` and +/// `strokepath` run out of a budget. #[test] fn work_on_the_longest_dash_pattern_ends_soon() { let dash = "0 0 10 10 rectfill /a 65535 array def 0 1 65534 { a exch 1 put } for a 0 setdash"; + let shared = run_looking(&format!("{dash} gsave gsave grestore gstate gstate currentgstate dup setgstate"), |it| { + let Some(saved) = it.saved.last().map(|g| g.dash.clone()) else { return false }; + let made: Vec<_> = it.stack.iter().filter_map(|o| if let Obj::GState(g) = o { Some(g.dash.clone()) } else { None }).collect(); + saved.len() == 65_535 && made.len() == 2 && made.iter().chain([&it.g.dash]).all(|m| std::rc::Rc::ptr_eq(m, &saved)) + }); + assert!(shared); for body in ["4000000 { gsave grestore } repeat", "2000000 { gstate currentgstate setgstate } repeat"] { let r = read_soon(&format!("{dash} {body} 20 20 10 10 rectfill")); assert_eq!(objects(&r.document).len(), 2, "{body}: {:?}", r.warnings); @@ -530,13 +540,15 @@ fn non_finite_arithmetic_is_undefinedresult() { assert!(!objects(&r.document).is_empty(), "{:?}", r.warnings); } -/// Tiling patterns and spot inks are found again through a hash of what tells them apart, so a -/// program that makes 200,000 patterns and 200,001 inks reads within [`read_soon`]'s 20 seconds. -/// Patterns are named Pattern 1, Pattern 2 and so on in the order they are made. A pattern made -/// again from its prototype is the same pattern, and so is an uncolored pattern set again in a -/// color it was set in before. +/// Tiling patterns are found again through a hash of what tells them apart. A program that sets +/// 200,000 different patterns and also sets 200,001 spot inks reads them all, and its 200,000 +/// pattern lookups compare at most 200,000 earlier patterns in all; going through every pattern +/// made so far would compare about 2 × 10^10. The test does not count or time the spot ink +/// lookups. Patterns are named Pattern 1, Pattern 2 and so on in the order they are made. A +/// pattern made again from its prototype is the same pattern, and so is an uncolored pattern set +/// again in a color it was set in before. #[test] -fn many_tiling_patterns_and_spot_inks_read_in_linear_time() { +fn many_tiling_patterns_are_found_again_through_a_hash() { let r = read( "/pp { pop 0 0 1 1 rectfill } def \ /a << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 1 1] /XStep 2 /YStep 2 /PaintProc /pp load >> def \ @@ -560,7 +572,7 @@ fn many_tiling_patterns_and_spot_inks_read_in_linear_time() { assert!(names.iter().all(|n| d.swatch(n).is_some_and(|s| matches!(s.paint, Paint::Pattern { .. })))); // 100,000 patterns whose cells differ, an uncolored pattern in 100,000 tints of an ink that is // white at every tint (only the tint tells those patterns apart), and 200,000 more inks. - let r = read_soon( + let (r, compared) = read_counting_tilings( "0 1 99999 { /i exch def << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 1 1] /XStep i 2 add /YStep 1 \ /PaintProc { pop 0 0 1 1 rectfill } >> matrix makepattern setpattern } for \ [/Separation (Spot) /DeviceGray { pop 1 }] setcolorspace \ @@ -568,6 +580,7 @@ fn many_tiling_patterns_and_spot_inks_read_in_linear_time() { matrix makepattern def 0 1 99999 { 100000 div p setpattern } for \ 0 1 199999 { /i exch def [/Separation i 8 string cvs /DeviceGray {}] setcolorspace } for 0 0 1 1 rectfill", ); + assert!(compared <= 200_000, "{compared} patterns compared"); let d = &r.document; assert_eq!(d.patterns.len(), 200_000); assert!(d.patterns.iter().enumerate().all(|(i, p)| p.name == format!("Pattern {}", i + 1))); @@ -1112,19 +1125,51 @@ fn hostile_executable_files_and_type_loops_are_refused() { blue("currentfile cvx exec currentfile flushfile 0 0 1 setrgbcolor"); } -/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes -/// more than 20 s. +/// How long [`read_soon`] and [`read_counting_tilings`] wait, in seconds: a guard against a read +/// that never ends. The budgets, limits and counts each test names decide what it checks. +const NEVER_ENDS: u64 = 300; + +/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading has not +/// finished after [`NEVER_ENDS`] seconds. fn read_soon(body: &str) -> crate::Imported { - read_within(body, 20) + read_within(body, NEVER_ENDS) } /// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes /// more than `secs` seconds. fn read_within(body: &str, secs: u64) -> crate::Imported { + read_counting(body, secs).0 +} + +/// [`read_soon`], with how many patterns the tiling pattern lookups compared while reading. +fn read_counting_tilings(body: &str) -> (crate::Imported, u64) { + read_counting(body, NEVER_ENDS) +} + +/// [`read_within`], with how many patterns the tiling pattern lookups compared while reading. +fn read_counting(body: &str, secs: u64) -> (crate::Imported, u64) { let (tx, rx) = std::sync::mpsc::channel(); let ps = eps(body); - std::thread::spawn(move || tx.send(import(&ps)).ok()); - rx.recv_timeout(std::time::Duration::from_secs(secs)).unwrap_or_else(|_| panic!("reading {body} did not finish")).unwrap() + std::thread::spawn(move || tx.send((import(&ps), tilings_compared())).ok()); + let (read, compared) = rx.recv_timeout(std::time::Duration::from_secs(secs)).unwrap_or_else(|_| panic!("reading {body} did not finish")); + (read.unwrap(), compared) +} + +/// Runs the program of `body` in an interpreter of its own on the 100 × 100 page of [`eps`]'s +/// bounding box, and returns what `look` finds in the interpreter when the program has ended. It +/// does not read the file's comments or make a document. The test fails when the program stops on +/// an error. +fn run_looking(body: &str, look: fn(&Interp) -> T) -> T { + let file = eps(body); + let ps = super::program(&file).unwrap(); + let mut doc = Document::new(100.0, 100.0); + doc.layers.clear(); + let page = vectorcraft_geom::Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 100.0]); + let mut it = Interp::new(ps, GState::default(), Out::new(doc, page, Rect::new(0.0, 0.0, 100.0, 100.0))); + assert!(it.run().is_ok(), "running {body} failed"); + let found = look(&it); + it.release(); + found } /// Reads `body` after a square: the square stays, and the program runs out of the operation From 4b9a611189047eeb691c362ca8fe206676708bb6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 08:30:44 -0800 Subject: [PATCH 16/34] artboard.new: keep sides below 1 pt as given artboard.new no longer raises a width or height below 1 pt to 1 pt, and artboard.setProps raises only a supplied side, so an edit to a small artboard's name or lock keeps its size. --- crates/engine/src/cmd/layer.rs | 17 +++++++++++++---- crates/engine/src/tests/newdoc.rs | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 43d9a8e05f..f93cdca7b9 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -850,14 +850,23 @@ fn move_node(s: &mut Session, p: &Value) -> Result { ok() } -fn rect_from(p: &Value, default: Rect) -> Rect { +/// The rectangle from `x`, `y`, `width` and `height`, each defaulting to `default`'s. Sides are kept as given. +fn rect_unclamped(p: &Value, default: Rect) -> Rect { let x = f64_or(p, "x", default.x0); let y = f64_or(p, "y", default.y0); - let w = f64_or(p, "width", default.width()).max(1.0); - let h = f64_or(p, "height", default.height()).max(1.0); + let w = f64_or(p, "width", default.width()); + let h = f64_or(p, "height", default.height()); Rect::new(x, y, x + w, y + h) } +/// [`rect_unclamped`] with each supplied side raised to at least 1 pt, as `artboard.setProps` uses it. +/// A side left out keeps `default`'s, even below 1 pt. +fn rect_from(p: &Value, default: Rect) -> Rect { + let r = rect_unclamped(p, default); + let floor = |key: &str, side: f64| if p.get(key).and_then(Value::as_f64).is_some() { side.max(1.0) } else { side }; + Rect::new(r.x0, r.y0, r.x0 + floor("width", r.width()), r.y0 + floor("height", r.height())) +} + /// An artboard command's `color` param, its background color: None when absent, `Some(None)` for /// null (no color), else `Some(Some(color))`. fn artboard_color(p: &Value, cmd: &str) -> Result>> { @@ -942,7 +951,7 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { src + side.offset(src) } None if p.get("beside").is_some_and(|v| !v.is_null()) => return Err(bad("artboard.new", "`beside` needs `side`")), - None => rect_from(p, def), + None => rect_unclamped(p, def), }; // A size taken from another artboard (`side`, or width or height left out) keeps to the limit too. if !super::newdoc::side_in_range(r.width()) || !super::newdoc::side_in_range(r.height()) { diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index 0b573a96f3..d2e5f1b115 100644 --- a/crates/engine/src/tests/newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -291,6 +291,20 @@ fn file_new_and_artboard_new_take_the_same_largest_side() { assert!(err.contains(in_meters), "{p}: {err}"); } assert_eq!(boards(&s).len(), 2); + + // A side below 1 pt is kept as given, as file.new keeps it, and a size taken from such an artboard keeps it too. + let mut s = new_doc(json!({"width": 0.5, "height": 0.25})); + assert_eq!(boards(&s), [Rect::new(0.0, 0.0, 0.5, 0.25)]); + s.execute("artboard.new", &json!({"x": 0, "y": 10, "width": 0.5, "height": 0.75})).unwrap(); + s.execute("artboard.new", &json!({"x": 0, "y": 20})).unwrap(); + assert_eq!(boards(&s)[1..], [Rect::new(0.0, 10.0, 0.5, 10.75), Rect::new(0.0, 20.0, 0.5, 20.75)]); + // artboard.setProps keeps such sides when it changes something else, on a locked artboard too, + // and still raises a supplied side to 1 pt. + s.execute("artboard.setProps", &json!({"index": 1, "name": "Small"})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 2, "locked": true})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 2, "name": "Small locked"})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 0, "height": 0.5})).unwrap(); + assert_eq!(boards(&s), [Rect::new(0.0, 0.0, 0.5, 1.0), Rect::new(0.0, 10.0, 0.5, 10.75), Rect::new(0.0, 20.0, 0.5, 20.75)]); } #[test] From da39713031860fee7e9efd7d4658c544f08c8ef4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 10:06:56 -0800 Subject: [PATCH 17/34] CSS: css.export refuses a scope or path that is not a string css.export read `scope` and `path` with a lenient string lookup, so a value of the wrong type (`5`, `true`, `["all"]`, `null`) was treated as left out: the selection was exported, or the CSS came back as data instead of being written. Both keys are now checked before anything is generated or written, and a supplied value that is not a string is an error ("`scope` must be a string", "`path` must be a string"). A left-out scope still exports the selection, and a left-out path still returns the CSS. css.generate and css.export with scope "all" do not use `ids` or `id`, but a malformed one is now an error there too, as it already is for the selection. --- crates/engine/src/cmd/css.rs | 30 ++++++++++++---- crates/engine/src/tests/css.rs | 63 ++++++++++++++++++++++++++++++++++ docs/mcp.md | 8 +++-- docs/target-app-parity.md | 1 + 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/css.rs b/crates/engine/src/cmd/css.rs index ed83a01410..dd6af8e6eb 100644 --- a/crates/engine/src/cmd/css.rs +++ b/crates/engine/src/cmd/css.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::NodeId; use vectorcraft_svg::{CssOptions, CssSheet, css_rules}; use super::fileio::{default_name, encode_with_warnings, isolated, write_file}; -use super::{CommandSpec, Result, Session, bad, cmd, has_doc, str_param, targets}; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, named_ids, targets}; pub fn specs() -> Vec { vec![ @@ -21,13 +21,13 @@ pub fn specs() -> Vec { has_doc, selection ), - cmd!(query "css.generate", "Generate CSS", [], None, "{…options as css.selection} css.selection of every object in the document", has_doc, generate), + cmd!(query "css.generate", "Generate CSS", [], None, "{…options as css.selection} css.selection of every object in the document; ids and id are not used, and a malformed one is an error", has_doc, generate), cmd!( "css.export", "Export CSS", [], None, - "{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: .css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once", + "{path?: string, scope?: selection|all (default selection when left out; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} a path or scope given as anything but a string, null included, is an error, and so is a malformed ids or id with scope all, which does not use them; write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: .css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once", has_doc, export ), @@ -77,16 +77,34 @@ fn selection(s: &mut Session, p: &Value) -> Result { Ok(result(&sheet(s, p, ids, "css.selection")?)) } +/// Checks the form of the `ids` or `id` that `p` gives a command that exports every object and does +/// not use them: a malformed one is an error, as it is for the selection. +fn unused_ids(p: &Value) -> Result<()> { + named_ids(p).map(|_| ()) +} + fn generate(s: &mut Session, p: &Value) -> Result { + unused_ids(p)?; let ids = everything(s)?; Ok(result(&sheet(s, p, ids, "css.generate")?)) } +/// The string `p` gives for `key`, or `None` when `p` leaves it out. Any value given that is not a +/// string, null included, is an error: "`{key}` must be a string". +fn given_text<'a>(p: &'a Value, key: &str, cmd: &str) -> Result> { + p.get(key).map(|v| v.as_str().ok_or_else(|| bad(cmd, format!("`{key}` must be a string")))).transpose() +} + fn export(s: &mut Session, p: &Value) -> Result { const C: &str = "css.export"; - let ids = match str_param(p, "scope").unwrap_or("selection") { + let scope = given_text(p, "scope", C)?; + let path = given_text(p, "path", C)?; + let ids = match scope.unwrap_or("selection") { "selection" => targets(s, p)?, - "all" => everything(s)?, + "all" => { + unused_ids(p)?; + everything(s)? + } other => return Err(bad(C, format!("scope must be selection or all (got {other})"))), }; let sheet = sheet(s, p, ids, C)?; @@ -115,7 +133,7 @@ fn export(s: &mut Session, p: &Value) -> Result { } } let n = sheet.rules.len(); - match str_param(p, "path") { + match path { Some(path) => { write_file(path, css.as_bytes())?; let folder = std::path::Path::new(path).parent(); diff --git a/crates/engine/src/tests/css.rs b/crates/engine/src/tests/css.rs index 764990b100..1ce5b09e41 100644 --- a/crates/engine/src/tests/css.rs +++ b/crates/engine/src/tests/css.rs @@ -98,6 +98,69 @@ fn export_writes_the_sheet_and_rasterized_art() { assert!(s.execute("css.export", &json!({})).is_err(), "nothing selected: nothing to export"); } +#[test] +fn export_refuses_a_scope_or_path_that_is_not_a_string() { + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40})); + run(&mut s, "shape.ellipse", json!({"x": 60, "y": 0, "width": 40, "height": 40})); + let refused = |s: &mut Session, p: Value, key: &str| { + let e = s.execute("css.export", &p).expect_err(&p.to_string()).to_string(); + assert_eq!(e, format!("invalid parameters for `css.export`: `{key}` must be a string"), "{p}"); + }; + for scope in [json!(5), json!(true), json!(["all"]), Value::Null] { + refused(&mut s, json!({"scope": scope}), "scope"); + } + for path in [json!(5), Value::Null] { + refused(&mut s, json!({"path": path}), "path"); + refused(&mut s, json!({"path": path, "scope": "all"}), "path"); + } + // Left out, each keeps its default: the selection (the ellipse), and the CSS as data. + let r = run(&mut s, "css.export", json!({})); + assert_eq!((r["rules"].clone(), r["data"].is_string()), (json!(1), true), "{r}"); + assert_eq!(run(&mut s, "css.export", json!({"scope": "all"}))["rules"], json!(2)); + let e = s.execute("css.export", &json!({"scope": "page"})).unwrap_err().to_string(); + assert!(e.contains("scope must be selection or all (got page)"), "{e}"); +} + +#[test] +fn every_object_refuses_malformed_ids_it_does_not_use() { + let mut s = session(); + let rect = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}))["id"].as_u64().unwrap(); + run(&mut s, "shape.ellipse", json!({"x": 60, "y": 0, "width": 40, "height": 40})); + for (cmd, base) in [("css.generate", json!({})), ("css.export", json!({"scope": "all"}))] { + for (key, value, says) in [ + ("ids", json!("abc"), "`ids` must be an array of object ids, not \"abc\""), + ("ids", json!([1.5]), "object ids are non-negative integers, not 1.5"), + ("id", json!(-1), "object ids are non-negative integers, not -1"), + ("id", json!("$step"), "`id` is the step reference \"$step\""), + ] { + let mut p = base.clone(); + p[key] = value; + let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}")).to_string(); + assert!(e.contains(says), "{cmd} {p}: {e}"); + } + // Well-formed ids are still not used: every object. + let mut p = base.clone(); + p["ids"] = json!([rect]); + let rules = run(&mut s, cmd, p)["rules"].clone(); + let count = rules.as_array().map_or(rules.as_u64(), |r| Some(r.len() as u64)); + assert_eq!(count, Some(2), "{cmd}: {rules}"); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn export_with_a_scope_that_is_not_a_string_writes_nothing() { + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40})); + let dir = std::env::temp_dir().join(format!("vc-css-scope-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("site.css"); + assert!(s.execute("css.export", &json!({"path": path.to_string_lossy(), "scope": 5})).is_err()); + assert!(!path.exists(), "no file for a refused scope"); + std::fs::remove_dir_all(&dir).ok(); +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn export_to_a_path_puts_the_pictures_next_to_the_sheet() { diff --git a/docs/mcp.md b/docs/mcp.md index 62a4212472..00b92b30ca 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6851,9 +6851,11 @@ says when CSS can't describe the art exactly (`unsupported`: other shapes, image written as its box, or with `rasterize: true` as `background-image: url(.png)`. `units` is `px` (1 px per point, as in SVG export), `pt`, `mm`, `cm` or `in`; `unnamed: false` leaves out unnamed objects (counted in `skipped`). `css.generate` does the same for the whole document, and `css.export {path?, scope?: selection|all}` -writes the `.css` file with the PNGs of rasterized art next to it (no path: `data` and `images` as base64). Its -`warnings` add the PNGs' notes to the CSS's, each once (an image that couldn't be decoded, type written in the -fallback font…). In the app, `css.copy` copies the CSS and `css.exportFile` writes it through a save panel (the web +writes the `.css` file with the PNGs of rasterized art next to it (no path: `data` and `images` as base64). A left-out +`scope` exports the selection; a `path` or `scope` given as anything but a string, `null` included, is an error, and +nothing is written. `css.generate` and `css.export {scope: "all"}` do not use `ids` or `id`, but a malformed one is still +an error. Its `warnings` add the PNGs' notes to the CSS's, each once (an image that couldn't be decoded, +type written in the fallback font…). In the app, `css.copy` copies the CSS and `css.exportFile` writes it through a save panel (the web downloads it) and shows the first note in the status bar, with how many there are; resting the pointer on the message lists up to 30 of them, fewer when the list would not fit in the window, with how many were left out. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5ec97e34c1..076b0976da 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -957,6 +957,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The command palette lists `select.key` as Cancel Key Object, and a pick cancels the key object (#541). Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784). Commands that act on `id` or else the selection, the highlighted layer row or a new object (`css.export`, `object.isolate`, `layer.locate`, `layer.releaseToLayers`, the graphic, character and paragraph style commands, `image.info`, the `links.*` and `shaper.*` commands that take an `id`, opacity mask editing and viewing, `symbol.edit`, the blend spine commands and `path.curvature`) refuse an `id` that is not an object id (`"abc"`, `"5"`, `true`) and change nothing; `effect.apply` with the effect given as `id` still applies it to the selection. + `css.export` refuses a `scope` or `path` that is not a string (`5`, `true`, `["all"]`, `null`) before writing anything, instead of exporting the selection or returning the CSS as data; a left-out `scope` still exports the selection, and a left-out `path` still returns the CSS. `css.generate` and `css.export {scope: "all"}` refuse a malformed `ids` or `id`, which they do not use, as the selection's export does. Agents read the key object: `select.key` returns it, and `document.inspect` and the `ui.pointer` and `pointer_gesture` replies report it as `key`. `select.key` with an id that isn't a selected top-level object returns an error and leaves the key unchanged. The Select menu's Same commands, its Object commands that select art, Next Object Above and Below, saved selections and the Magic Wand (#2655) select only objects that Select All selects and objects inside them: nothing hidden or locked, nothing in a hidden or locked group, layer or sublayer, nothing on a template layer, and in isolation mode nothing outside the isolated group or layer. In isolation mode Select All, All on Active Artboard and Inverse select nothing while the isolated group or layer, or a group or layer it is in, is hidden, locked or a template layer. Select ▸ Object ▸ Bristle Brush Strokes selects the objects whose topmost stroke has a bristle brush, whatever the brush is named, and leaves out objects whose brush is of another type, also when its name has "Bristle" in it. From 70a4c44033149f0b1e445606513cb947d6874d77 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 10:08:53 -0800 Subject: [PATCH 18/34] Effects: every fill a painted effect returns follows the Transform effect A painted live effect on a fill can return a piece with several fills, each a copy of that fill with its effects. A Transform effect after it moved the piece, but only the first returned fill's raster effects followed it: with a Drop Shadow before the painted effect and a 200% Transform effect after it, the first fill's shadow offset became 20 pt and the second fill's stayed 10 pt. Each returned fill and stroke now takes the followed values. --- crates/effects/src/reshape.rs | 25 ++++++++++++++----------- crates/engine/src/tests/plugins.rs | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index e1e99b1a80..58f083ef68 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -1815,22 +1815,25 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), h }); } -/// Make the raster effects of `leaf`'s first fill or stroke from index `from` on follow Transform -/// effect `t`, listed after them, once `t` has moved the leaf ([`crate::raster::follow_one`]). -/// `was` holds those effects as they were before `t` was applied: each one that follows takes the -/// value [`crate::raster::followed`] gives it, in place of what Scale Strokes & Effects made of it, -/// and the others keep theirs. +/// Make the raster effects of each of `leaf`'s fills and strokes from index `from` on follow +/// Transform effect `t`, listed after them, once `t` has moved the leaf +/// ([`crate::raster::follow_one`]). The fills and strokes are copies of one fill or stroke (those a +/// painted plug-in returns, [`vectorcraft_plugins::painted::items`]), with the same effects. `was` +/// holds those effects of the first one as they were before `t` was applied: each one that +/// follows takes the value [`crate::raster::followed`] gives it, in place of what Scale Strokes & +/// Effects made of it, and the others keep theirs. fn follow_piece(leaf: &mut Node, was: &[Effect], from: usize, t: &Effect) { let mut follow = was.to_vec(); if !crate::raster::follow_one(&mut follow, t) { return; } - let Some(item) = leaf.appearance.items.first_mut() else { return }; - for (k, (new, old)) in follow.into_iter().zip(was).enumerate() { - if new != *old - && let Some(e) = item.effects_mut().get_mut(from + k) - { - *e = new; + for item in &mut leaf.appearance.items { + for (k, (new, old)) in follow.iter().zip(was).enumerate() { + if new != old + && let Some(e) = item.effects_mut().get_mut(from + k) + { + *e = new.clone(); + } } } } diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 8d1570e996..be4193a812 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -452,6 +452,30 @@ fn a_painted_effect_on_a_fill_and_refused_results() { } } +/// A fill's Drop Shadow before a painted effect that returns two fills, and a 200% Transform effect +/// after it: the shadow of each returned fill follows the Transform effect. The square the effect +/// returns spans 30 to 70 pt once scaled, so both half-opaque shadows, 20 pt to the right, cover +/// 85 pt as they cover 75 pt. +#[test] +fn a_shadow_on_each_fill_a_painted_effect_returns_follows_the_transform_after_it() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00"), solid("#0000ff")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.twofills", true), &out)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + for (effect, params) in [ + ("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 50})), + ("plugin.test.engine.twofills", json!({})), + ("distort.transform", json!({"scaleH": 200, "scaleV": 200})), + ] { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "params": params, "item": fill})); + } + let img = drawn(&s); + let (near, far) = (img.pixel(75, 50), img.pixel(85, 50)); + assert_ne!(near, WHITE, "the shadows reach past the square"); + assert_eq!(far, near, "both shadows at 20 pt"); +} + /// A result refused for a swatch the document doesn't have stays cached: once the document has the /// swatch, the next check of that result clears the error. #[test] From 1132cf0c79524bacd953e70712898ea38ca2fa49 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 10:18:21 -0800 Subject: [PATCH 19/34] SVG: Save As SVG can include the document's variables and data sets SVG Options gets an Include Variable Data checkbox (the `variableData` option, off by default). With it on, the saved SVG carries the document's text variables, its data sets' text values and each text variable's bound type object as a `variableSets` element, in the layout Load Variable Library already reads. A bound object is named by its element id in a `docRef="id('...')"` attribute; an object without an id gets one. Opening such an SVG reads the variable data back: the text variables, the data sets (none active) and the bindings, so choosing a data set changes the type again. Variable data that Load Variable Library would refuse is left out with a warning, and the art opens. Markup that only looks like the element (in comments, CDATA sections, processing instructions or the document type declaration) is skipped. Variables of other kinds, names XML can't hold, more than 256 text variables or 1,000 data sets, and variable data over 8 MiB or 2,000,000 XML nodes are left out of the file, each with a warning. Type written as outlines opens as art, so its variables are written unbound, with a warning. --- crates/engine/src/cmd/fileio/commands.rs | 4 +- crates/engine/src/cmd/fileio/load.rs | 31 ++- crates/engine/src/cmd/fileio/svg.rs | 6 + crates/engine/src/cmd/variablecmds.rs | 1 + crates/engine/src/cmd/variablecmds/library.rs | 178 +++++++++++++- crates/engine/src/tests/variables.rs | 188 +++++++++++++++ crates/svg/src/export.rs | 9 + crates/svg/src/export/output.rs | 5 +- crates/svg/src/export/text.rs | 1 + crates/svg/src/export/variables.rs | 223 ++++++++++++++++++ crates/svg/src/import.rs | 19 +- crates/svg/src/import/importer.rs | 8 +- crates/svg/src/lib.rs | 14 ++ crates/ui-egui/src/dialogs/svg_options.rs | 21 ++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/file-format-parity.md | 2 +- docs/mcp.md | 15 +- 43 files changed, 735 insertions(+), 17 deletions(-) create mode 100644 crates/svg/src/export/variables.rs diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 7f95b8e203..fe7c9d8f3e 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", always, load::open ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), 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?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), 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, after a central {stem}.eps of the whole document when there are two or more and art shows; 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), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), 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}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), 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?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), 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, variableData} (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, after a central {stem}.eps of the whole document when there are two or more and art shows; 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), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), 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}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", has_doc, export::export ), diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index ec1d7e36ba..052b1683d8 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -398,7 +398,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let editing = vectorcraft_svg::editing(&text).map(|e| (e.intact, move || vectorcraft_format::base64_decode(&e.data))); // Relative links are found in the SVG's folder (when `name` is a path). let folder = std::path::Path::new(name).parent().map(|f| f.to_string_lossy()).filter(|f| !f.is_empty()).map(|f| absolute_path(&f)); - restore_or_import(editing, || import_svg(&text, folder.as_deref()))? + restore_or_import(editing, || open_svg(&text, folder.as_deref()))? } "pdf" | "ai" | "ait" => { // The file is read and checked once, for the document it carries, its pages, the @@ -573,6 +573,33 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result /// own): rasters stay linked, SVG files become art, missing ones placeholders (see /// [`vectorcraft_svg::ImportOptions`]). pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec)> { + import_svg_finding(text, folder, &std::collections::HashSet::new()).map(|(doc, warnings, _)| (doc, warnings)) +} + +/// [`import_svg`] for a document opened from an SVG: with the variable data the file carries (SVG +/// Options › Include Variable Data), read as [`crate::cmd::variablecmds::svg_library`] says. Variable +/// data that can't be read is left out with a warning; the art opens either way. +fn open_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec)> { + let lib = match crate::cmd::variablecmds::svg_library(text) { + None => return import_svg(text, folder), + Some(Err(e)) => { + let (doc, mut warnings) = import_svg(text, folder)?; + warnings.push(format!("the file's variable data wasn't read: {e}")); + return Ok((doc, warnings)); + } + Some(Ok(lib)) => lib, + }; + let (mut doc, mut warnings, found) = import_svg_finding(text, folder, &lib.elements())?; + warnings.extend(crate::cmd::variablecmds::give_svg_library(&mut doc, lib, &found)); + Ok((doc, warnings)) +} + +/// [`import_svg`], also returning the object made of each element whose id `ids` lists. +fn import_svg_finding( + text: &str, + folder: Option<&str>, + ids: &std::collections::HashSet, +) -> Result<(Document, Vec, vectorcraft_svg::Found)> { // The importer reads on a thread of its own (for its stack): what may be read is what may be // read here. let scope = crate::file_access::ScopeHandle::capture(); @@ -588,7 +615,7 @@ pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Some((bytes, link)) }) }; - vectorcraft_svg::import_with(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }).map_err(err) + vectorcraft_svg::import_finding(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }, ids).map_err(err) } /// Open a file's bytes as the new active document (what `document.open` does) → diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 3123fdcfb1..eebac553d3 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -90,6 +90,12 @@ pub const OPTIONS: &[FormatOption] = &[ default: "false", description: "embed the fonts type uses as @font-face, subset to the characters used (whole when the font's licence forbids subsetting, left out with a warning when it forbids embedding)", }, + FormatOption { + name: "variableData", + ty: "boolean", + default: "false", + description: "Include Variable Data: write the text variables, the data sets and the type object each text variable is bound to as a variable library (variableSets) that document.open and variable.loadLibrary read back; variables of other kinds, and what is past the limits variable.loadLibrary reads (256 text variables, 1,000 data sets, 8 MiB), are left out with a warning", + }, FormatOption { name: "svg", ty: "object", diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index ad70f047bc..241487112b 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -27,6 +27,7 @@ mod csv; mod library; use csv::{MAX_GRAPH_CSV, dataset_export, dataset_import}; +pub(crate) use library::{give_svg_library, svg_library}; use library::{load_library, save_library}; /// The longest variable or dataset name, in characters (names arrive from MCP and files). diff --git a/crates/engine/src/cmd/variablecmds/library.rs b/crates/engine/src/cmd/variablecmds/library.rs index 54f3689e99..e1f4ae0bd5 100644 --- a/crates/engine/src/cmd/variablecmds/library.rs +++ b/crates/engine/src/cmd/variablecmds/library.rs @@ -1,6 +1,8 @@ +use std::collections::HashMap; + use super::{ - BTreeMap, BTreeSet, DataSet, DataValue, EngineError, MAX_NAME, Result, Session, Value, Variable, VariableKind, absolute_path, bad, json, named, - read_file_head, str_param, variables_unlocked, write_file, + BTreeMap, BTreeSet, DataSet, DataValue, Document, EngineError, MAX_NAME, NodeId, NodeKind, Result, Session, Value, Variable, VariableKind, + Variables, absolute_path, bad, json, named, read_file_head, str_param, variables_unlocked, write_file, }; /// The namespace of a variable library's elements, the default namespace of its data sets' values, @@ -169,12 +171,20 @@ fn without_doctype(xml: &str, cmd: &str) -> Result { } /// A variable library as Load Variable Library reads it: the names of its text variables in the -/// order it declares them, the name and `trait` of each variable of another kind, and its data -/// sets with their text values. -struct Library { +/// order it declares them, the name and `trait` of each variable of another kind, its data sets +/// with their text values, and the element id each text variable's `docRef` names. +pub(crate) struct Library { text: Vec, other: Vec<(String, String)>, datasets: Vec, + refs: Vec<(String, String)>, +} + +impl Library { + /// The element ids the text variables' `docRef`s name. + pub(crate) fn elements(&self) -> std::collections::HashSet { + self.refs.iter().map(|(_, e)| e.clone()).collect() + } } /// Read a variable library: XML whose `variableSets` element holds `variableSet` elements, each @@ -200,7 +210,7 @@ fn parse_library(xml: &str, cmd: &str) -> Result { return Err(bad(cmd, "the file holds no `variableSets` element")); }; let sets: Vec = root.children().filter(|n| is(n, "variableSet")).collect(); - let (mut text, mut other, mut declared) = (Vec::new(), Vec::new(), BTreeSet::new()); + let (mut text, mut other, mut declared, mut refs) = (Vec::new(), Vec::new(), BTreeSet::new(), Vec::new()); for var in sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "variables")).flat_map(|v| v.children()).filter(|n| is(n, "variable")) { let name = named(var.attribute("varName"), "varName", cmd)?; if !declared.insert(name.clone()) { @@ -210,7 +220,14 @@ fn parse_library(xml: &str, cmd: &str) -> Result { return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_VARIABLES} variables"))); } match var.attribute("trait") { - Some("textcontent") => text.push(name), + Some("textcontent") => { + // `docRef="id('…')"` names the element of the variable's bound object. + let element = var.attribute("docRef").and_then(|r| r.trim().strip_prefix("id('")?.strip_suffix("')")).filter(|e| !e.is_empty()); + if let Some(element) = element { + refs.push((name.clone(), element.to_string())); + } + text.push(name); + } Some(t) => other.push((name, t.chars().take(MAX_NAME).collect())), None => return Err(bad(cmd, format!("variable `{name}` has no `trait`"))), } @@ -253,7 +270,152 @@ fn parse_library(xml: &str, cmd: &str) -> Result { } datasets.push(DataSet { name, values }); } - Ok(Library { text, other, datasets }) + Ok(Library { text, other, datasets, refs }) +} + +/// The variable library an SVG carries (SVG Options › Include Variable Data), read as +/// [`parse_library`] reads one, or `None` when the SVG holds no `variableSets` element. The +/// element is read on its own, from its start tag to the end tag that closes it +/// ([`variable_sets_range`]), with each reference to an +/// entity whose name starts with `ns_` replaced by the value the text before the element declares +/// for it (up to [`MAX_LIBRARY_ENTITIES`] of them, plain text of at most [`MAX_NAME`] bytes), as +/// files name the library's namespaces with such entities. The `ns_vars`, `ns_custom` and +/// `ns_flows` entities take the library's namespaces when the file doesn't declare them. An +/// element larger than [`MAX_LIBRARY_BYTES`], before or after the replacements, is refused. +pub(crate) fn svg_library(svg: &str) -> Option> { + const C: &str = "document.open"; + let (start, end) = variable_sets_range(svg)?; + let read = || { + let end = end.ok_or_else(|| bad(C, "the variable library declares no variables"))?; + let element = svg.get(start..end).unwrap_or_default(); + if element.len() > MAX_LIBRARY_BYTES { + return Err(bad(C, format!("the variable library is larger than {MAX_LIBRARY_BYTES} bytes"))); + } + let mut entities: Vec<(String, String)> = Vec::new(); + for decl in svg.get(..start).unwrap_or_default().split("(); + if element.len() + grown > MAX_LIBRARY_BYTES { + return Err(bad(C, format!("the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes"))); + } + let mut element = element.to_string(); + for (name, value) in &entities { + element = element.replace(name.as_str(), value); + } + parse_library(&format!("{element}"), C) + }; + Some(read()) +} + +/// Where the first `variableSets` element of `svg` that is markup starts, and where the end tag +/// that closes it ends (`None` when no end tag does), or `None` when there is no such element. +/// Comments, CDATA sections, processing instructions and the document type declaration are +/// skipped, so text inside them that looks like the element is not taken for it. +fn variable_sets_range(svg: &str) -> Option<(usize, Option)> { + const NAME: &str = "variableSets"; + // The element's name ends at white space, `>` or `/`. + let named = |rest: &str| rest.strip_prefix(NAME).is_some_and(|r| r.starts_with(|c: char| c.is_ascii_whitespace() || matches!(c, '>' | '/'))); + if !svg.contains("") + } else if rest.starts_with("") + } else if rest.starts_with("") + } else if rest.starts_with("`. + match (rest.find('['), rest.find('>')) { + (Some(open), Some(gt)) if open < gt => { + rest.get(open..).and_then(|s| s.find(']')).and_then(|c| rest.get(open + c..)?.find('>').map(|g| at + open + c + g + 1)) + } + (_, gt) => gt.map(|g| at + g + 1), + } + } else if let Some(close) = rest.strip_prefix(""); + if let Some(s) = start + && named(close) + { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some((s, tag_end)); + } + } + tag_end + } else { + let tag_end = past(">"); + if named(rest.get(1..).unwrap_or_default()) { + let empty = tag_end.and_then(|e| svg.get(..e)).is_some_and(|t| t.ends_with("/>")); + if start.is_none() { + start = Some(at); + } + if empty && depth == 0 { + return Some((at, tag_end)); + } + depth += usize::from(!empty); + } + tag_end + }; + let Some(next) = next else { break }; + at = next; + } + start.map(|s| (s, None)) +} + +/// Give `doc`, opened from an SVG, the variable data `lib` it carries ([`svg_library`]): the +/// library's text variables, its data sets, no active data set, and each text variable bound to +/// the type object made of the element its `docRef` names, or the one type object among the +/// objects a group made of it holds (`found`: the object made of each element, by id). A `docRef` +/// that names no type object, or one another variable is bound to already, binds nothing. The art +/// stays as the file draws it. The warning about the variables of other kinds that were left out, +/// if any. +pub(crate) fn give_svg_library(doc: &mut Document, lib: Library, found: &HashMap) -> Option { + let mut vars = + Variables { variables: lib.text.iter().map(|n| Variable { name: n.clone(), kind: VariableKind::Text }).collect(), ..Variables::default() }; + vars.datasets = lib.datasets; + // A type object written with a paint of its own, or with fills and strokes of its appearance, + // is a group around its text (and the appearance's paths). + let text_of = |id: NodeId| match doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(_)) => Some(id), + Some(NodeKind::Group { children, .. }) => { + let mut texts = children.iter().filter(|c| matches!(c.kind, NodeKind::Text(_))); + match (texts.next(), texts.next()) { + (Some(one), None) => Some(one.id), + _ => None, + } + } + _ => None, + }; + for (name, element) in &lib.refs { + let Some(id) = found.get(element).and_then(|id| text_of(*id)) else { continue }; + if !vars.bindings.contains_key(&id) { + vars.bind(id, name); + } + } + doc.variables = vars.into(); + left_out_warning(lib.other.iter().map(|(_, t)| t.as_str())) } /// Load Variable Library: replace the document's variables and data sets with a variable library's diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index 4b773c889c..08ba426ef1 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -2916,3 +2916,191 @@ fn applying_a_graph_row_again_records_no_undo_step() { s.execute("dataset.select", &json!({"name": "B"})).unwrap(); assert_eq!(super::history(&s), (before.0 + 1, 0, true)); } + +/// The document's SVG with the options `svg` → (its text, the export's warnings). +fn svg_export(s: &mut Session, svg: Value) -> (String, Vec) { + let r = s.execute("document.export", &json!({"format": "svg", "svg": svg})).unwrap(); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + (String::from_utf8(bytes).unwrap(), r["warnings"].as_array().cloned().unwrap_or_default()) +} + +/// A new session with `svg` opened as `name` → (the session, the open's warnings). +fn svg_opened(svg: &str, name: &str) -> (Session, Vec) { + let mut o = Session::new(); + let r = o.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); + let warnings = r["warnings"].as_array().cloned().unwrap_or_default(); + (o, warnings) +} + +/// Include Variable Data writes the text variables, the data sets' text values and each text +/// variable's bound type object into the SVG, and opening the SVG reads them back: the type +/// objects are bound again, so a data set changes them. Variables of other kinds are left out with +/// a warning. Unnamed objects get ids with Minimal ids, minified files, unique ids and style +/// entities alike, and Load Variable Library reads the same file. +#[test] +fn an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let u = text(&mut s, "9.99"); + // A fill of its appearance writes the type in a group with the fill's path. + s.execute("appearance.addFill", &json!({"ids": [u.0]})).unwrap(); + let r = rect(&mut s); + for (name, kind) in [("Name", "text"), ("Shown", "visibility"), ("Price", "text")] { + s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap(); + } + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Price", "ids": [u.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Shown", "ids": [r.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "A & ", "values": {"Name": "Bob", "Price": "1.00\nper pound & tax\r", "Shown": false}})).unwrap(); + s.execute("dataset.new", &json!({"name": "Second", "values": {"Name": ""}})).unwrap(); + let before = s.doc().unwrap().doc.variables.clone(); + let text_only: Vec = before + .datasets + .iter() + .map(|d| { + let mut d = d.clone(); + d.values.retain(|k, _| k != "Shown"); + d + }) + .collect(); + + for opts in [ + json!({"variableData": true}), + json!({"variableData": true, "objectIds": "minimal", "minify": true}), + json!({"variableData": true, "objectIds": "unique"}), + json!({"variableData": true, "styling": "entities", "encoding": "latin1"}), + ] { + let (svg, warnings) = svg_export(&mut s, opts.clone()); + assert!(warnings.contains(&json!("Variables that aren't text variables were left out of the SVG's variable data")), "{opts}: {warnings:?}"); + assert_eq!(svg.matches("docRef=\"id('").count(), 2, "{opts}: {svg}"); + assert_eq!(s.doc().unwrap().doc.variables, before, "{opts}: exporting changes nothing"); + + let (mut o, warnings) = svg_opened(&svg, "vars.svg"); + assert!(warnings.is_empty(), "{opts}: {warnings:?}"); + let vars = o.doc().unwrap().doc.variables.clone(); + assert_eq!(vars.variables.iter().map(|v| (v.name.as_str(), v.kind.label())).collect::>(), [("Name", "text"), ("Price", "text")]); + assert_eq!(vars.datasets, text_only, "{opts}"); + assert_eq!(vars.active_dataset, None); + let bound = |name: &str| { + let ids = vars.objects_of(name); + assert_eq!(ids.len(), 1, "{opts}: {name}"); + ids[0] + }; + let (name, price) = (bound("Name"), bound("Price")); + assert_eq!((plain(&o, name), plain(&o, price)), ("Alice".into(), "9.99".into()), "{opts}: the art stays as the file draws it"); + let v = o.execute("dataset.select", &json!({"name": "A & "})).unwrap(); + assert_eq!(v["applied"], json!(2), "{opts}: {v}"); + assert_eq!(plain(&o, name), "Bob"); + + let mut lib = session(); + lib.execute("variable.loadLibrary", &json!({"xml": svg})).unwrap(); + assert_eq!(lib.doc().unwrap().doc.variables.datasets, text_only, "{opts}"); + } +} + +/// Without Include Variable Data the SVG holds no variable data, and a document without variables +/// writes none with it on. Outlined type is written unbound, characters XML can't hold are left +/// out of the values, and variable data past the 8 MiB or 2,000,000-node cap is left out, each +/// with a warning; past 256 text variables or 1,000 data sets the rest are left out with a +/// warning. Look-alike text in comments and other markup is skipped when the file opens. A file +/// whose variable data can't be read opens its art with a warning, and so does one whose +/// namespace entities would grow the library past its cap. +#[test] +fn an_svg_without_readable_variable_data_opens_its_art() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let (plain_svg, _) = svg_export(&mut s, json!({})); + assert!(!plain_svg.contains("variableSets")); + let (none, _) = svg_export(&mut s, json!({"variableData": true})); + assert!(!none.contains("variableSets"), "no variables, no variable data"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + let (off, _) = svg_export(&mut s, json!({})); + assert!(!off.contains("variableSets"), "off by default"); + // Outlined type opens as art: the variable is written unbound, and the values lose the + // characters XML can't hold, each with a warning. + s.execute("dataset.new", &json!({"name": "Row", "values": {"Name": "a\u{b}b\u{1}c"}})).unwrap(); + let (outlined, warnings) = svg_export(&mut s, json!({"variableData": true, "outlineText": true})); + assert!(outlined.contains(""; + let art = ""; + let untraited = format!("{head}{art}"); + let refs = "&ns_big;".repeat(40_000); + let big = "x".repeat(250); + let growing = format!( + "]>{head}{art}" + ); + // Variable data under 8 MiB with more XML nodes than Load Variable Library reads (a million + // one-character paragraphs, minified) is left out with a warning. + let mut lines = session(); + lines.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + lines.execute("dataset.new", &json!({"name": "Row", "values": {"Name": "a\n".repeat(1_000_000)}})).unwrap(); + let (dense, warnings) = svg_export(&mut lines, json!({"variableData": true, "minify": true})); + assert!(!dense.contains("variableSets")); + assert!( + warnings.contains(&json!( + "The variable data holds more than 2,000,000 XML nodes, which Load Variable Library reads, and was left out of the SVG" + )), + "{warnings:?}" + ); + + // More text variables and data sets than Load Variable Library reads: the first 256 and the + // first 1,000 are written, each with a warning, and the file opens with them. + let mut many = session(); + { + let vars = &mut std::sync::Arc::make_mut(&mut many.doc_mut().unwrap().doc).variables; + vars.variables = (0..300).map(|i| vectorcraft_doc::Variable { name: format!("V{i}"), kind: vectorcraft_doc::VariableKind::Text }).collect(); + vars.datasets = (0..1_100) + .map(|i| vectorcraft_doc::DataSet { + name: format!("D{i}"), + values: [("V0".to_string(), vectorcraft_doc::DataValue::Text(format!("{i}")))].into_iter().collect(), + }) + .collect(); + } + let (capped, warnings) = svg_export(&mut many, json!({"variableData": true})); + for w in [ + "Text variables past the first 256, which Load Variable Library reads, were left out of the SVG's variable data", + "Data sets past the first 1,000, which Load Variable Library reads, were left out of the SVG's variable data", + ] { + assert!(warnings.contains(&json!(w)), "{warnings:?}"); + } + let (o, warnings) = svg_opened(&capped, "many.svg"); + assert!(warnings.is_empty(), "{warnings:?}"); + let vars = &o.doc().unwrap().doc.variables; + assert_eq!((vars.variables.len(), vars.datasets.len()), (256, 1_000)); + assert_eq!(vars.datasets[999].values["V0"], vectorcraft_doc::DataValue::Text("999".into())); + + // Text that only looks like variable data, in a comment, a CDATA section, a processing + // instruction or the document type declaration, is skipped for the element itself. + let library = ""; + let decoys = format!( + " -->]>{head} ]]> ?>{library}{art}" + ); + let (o, warnings) = svg_opened(&decoys, "decoys.svg"); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(o.doc().unwrap().doc.variables.variables.len(), 1); + + for (svg, why) in [(untraited, "variable `A` has no `trait`"), (growing, "entities expand to more than")] { + let (o, warnings) = svg_opened(&svg, "broken.svg"); + assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("variable data wasn't read") && w.contains(why))), "{warnings:?}"); + let d = &o.doc().unwrap().doc; + assert!(d.variables.variables.is_empty()); + assert_eq!(vectorcraft_testkit::svg_shapes(&vectorcraft_svg::export(d, &Default::default())), 1, "the art opens"); + } +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 8eb18626f3..5fed1fb121 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -26,6 +26,7 @@ mod nodes; mod output; mod paint; mod text; +mod variables; use nodes::SymbolDef; pub(crate) use nodes::{MASK_FLAGS, Reuse}; @@ -117,8 +118,10 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: layer_nest: 0, non_latin1_names: false, foreign_ns: BTreeMap::new(), + bound: HashMap::new(), }; w.assign_name_ids(); + w.assign_bound_ids(); for layer in &doc.layers { w.count_colorized_images(layer); } @@ -135,6 +138,8 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: w.line(""); } + // Include Variable Data, once the body has written the bound objects' ids. + let variables = w.variable_sets(); // XML names cannot use character references. Preserve names that Latin-1 cannot encode. let encoding = if opts.encoding == Encoding::Latin1 && w.non_latin1_names { w.warn("Unicode data attribute names require UTF-8; SVG was written in UTF-8"); @@ -207,6 +212,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: out.push_str(&w.indent(1)); out.push_str(&format!("